Forráskód Böngészése

feat: checkpoint persistent session and audit runtime

Problem: completed todos 30-42 remained uncommitted, which made later per-todo commits overlap existing runtime, persistence, audit, and session UI changes.

Risk: this checkpoint groups previously completed todos into one commit because their changes were already interleaved in the working tree; behavior is covered by the existing 74-test baseline.
zhenyu.hu 2 hete
szülő
commit
3c8ac6aec7
31 módosított fájl, 3382 hozzáadás és 47 törlés
  1. 3 0
      .gitignore
  2. 32 0
      docs/plans/todo-30-sqlite-session-store.md
  3. 26 0
      docs/plans/todo-31-runtime-persistence.md
  4. 26 0
      docs/plans/todo-32-session-ui.md
  5. 24 0
      docs/plans/todo-33-audit-replay-stream-ui.md
  6. 22 0
      docs/plans/todo-34-event-agent-history-boundary.md
  7. 23 0
      docs/plans/todo-35-audit-modal-raw-llm-events.md
  8. 24 0
      docs/plans/todo-36-audit-detail-compact-ui.md
  9. 25 0
      docs/plans/todo-37-audit-detail-flow-layout.md
  10. 22 0
      docs/plans/todo-38-audit-round-event-semantics.md
  11. 19 0
      docs/plans/todo-39-audit-turn-relative-time.md
  12. 21 0
      docs/plans/todo-40-chat-message-stream-audit.md
  13. 18 0
      docs/plans/todo-41-session-button-feedback.md
  14. 19 0
      docs/plans/todo-42-session-switch-idle-socket.md
  15. 13 0
      docs/plans/todos.md
  16. 1 0
      src/agent_lab/application/contracts.py
  17. 28 0
      src/agent_lab/application/event_agent.py
  18. 289 20
      src/agent_lab/application/runtime.py
  19. 85 0
      src/agent_lab/application/session_store.py
  20. 6 1
      src/agent_lab/domain/messages.py
  21. 1 0
      src/agent_lab/infrastructure/chat_client.py
  22. 484 0
      src/agent_lab/infrastructure/sqlite_store.py
  23. 740 15
      src/agent_lab/presentation/static/app.js
  24. 71 3
      src/agent_lab/presentation/static/index.html
  25. 441 0
      src/agent_lab/presentation/static/styles.css
  26. 57 2
      src/agent_lab/presentation/web.py
  27. 1 0
      src/agent_lab/settings.py
  28. 472 2
      tests/test_debug_runtime.py
  29. 40 0
      tests/test_event_agent.py
  30. 97 0
      tests/test_sqlite_store.py
  31. 252 4
      tests/test_websocket_api.py

+ 3 - 0
.gitignore

@@ -66,3 +66,6 @@ target/
 .env
 .venv/
 .superpowers/
+*.db
+*.sqlite
+*.sqlite3

+ 32 - 0
docs/plans/todo-30-sqlite-session-store.md

@@ -0,0 +1,32 @@
+# Todo 30: SQLite Session Store
+
+## Status
+
+done
+
+## Goal
+
+Persist debugging-console data in SQLite and expose read APIs for session list, chat messages, audit replay, and usage summaries.
+
+## Scope
+
+- Add a small synchronous SQLite store under `infrastructure/`.
+- Keep the store independent from runtime orchestration so it can be tested directly.
+- Add FastAPI endpoints for creating/listing sessions and reading messages, audit logs, and usage.
+- Add `AGENT_LAB_DATABASE_PATH` via `Settings.database_path`.
+- Ignore local SQLite database files in git.
+
+## Data Model
+
+- `sessions`: id, title, timestamps, full workspace/agent config JSON.
+- `turns`: session id, turn index, user message, timestamps.
+- `messages`: ordered chat transcript rows.
+- `audit_logs`: ordered audit rows with event name and JSON details.
+- `usage_stats`: one row per model call; REST response also returns turn and session aggregates.
+
+## Verification
+
+- Direct store tests cover create/list/detail/messages/audit/usage, including a regression for usage totals not being multiplied by turn joins.
+- API tests cover `/api/sessions` and session detail/replay endpoints with empty and non-empty data.
+- `uv run pytest tests/test_sqlite_store.py tests/test_websocket_api.py -q` passes: 36 tests.
+- `uv run pytest` passes: 66 tests.

+ 26 - 0
docs/plans/todo-31-runtime-persistence.md

@@ -0,0 +1,26 @@
+# Todo 31: Runtime Persistence
+
+## Status
+
+done
+
+## Goal
+
+Persist live WebSocket runtime data into the SQLite session store so completed turns can be replayed through the REST APIs.
+
+## Scope
+
+- Add an optional `session_id` to `DebugRunRequest`.
+- Let the WebSocket runtime create or reuse a persisted session.
+- Persist Agent config snapshots when a session is created or reused.
+- Persist user and assistant messages for each turn.
+- Persist audit entries with turn and round indexes.
+- Persist one usage row per ChatAgent model call.
+- Continue turn indexes for an existing persisted session.
+
+## Verification
+
+- Runtime tests prove session creation, turn/message persistence, audit rows, usage rows, and existing-session turn indexes.
+- Existing queue, audit, WebSocket, and store tests still pass.
+- `uv run pytest tests/test_sqlite_store.py tests/test_debug_runtime.py tests/test_websocket_api.py -q` passes: 55 tests.
+- `uv run pytest` passes: 66 tests.

+ 26 - 0
docs/plans/todo-32-session-ui.md

@@ -0,0 +1,26 @@
+# Todo 32: Session UI
+
+## Status
+
+done
+
+## Goal
+
+Expose persisted sessions in the debug console so users can create/load sessions, replay chat messages and audit logs, and inspect call/turn/session usage.
+
+## Scope
+
+- Add sidebar session controls for creating, refreshing, selecting, and loading sessions.
+- Send the selected `session_id` in the first WebSocket request while preserving lightweight follow-up turn messages.
+- Handle `session_started.session_id` from the backend.
+- Add a separate audit replay panel.
+- Add session usage displays for aggregate, turn, and model-call usage.
+- Keep live round stats separate from session aggregate usage.
+
+## Verification
+
+- Static UI tests cover session controls, replay panels, session API calls, `session_id` WebSocket payloads, and usage render hooks.
+- WebSocket tests still prove multi-turn reuse behavior.
+- `node --check src/agent_lab/presentation/static/app.js` passes.
+- `uv run pytest tests/test_websocket_api.py -q` passes: 34 tests.
+- `uv run pytest` passes: 66 tests.

+ 24 - 0
docs/plans/todo-33-audit-replay-stream-ui.md

@@ -0,0 +1,24 @@
+# Todo 33: Audit Replay Event Stream UI
+
+## Status
+
+done
+
+## Goal
+
+Redesign Audit Replay as a scrollable event stream with click-to-inspect details, keep audit events out of the chat transcript, and lock the sidebar controls plus chat composer while letting chat and replay panes scroll independently.
+
+## Plan
+
+1. Split live audit handling from chat message rendering.
+2. Add an Audit Detail panel that renders event-specific sections for agent requests, responses, detected events, round lifecycle, usage, and generic details.
+3. Restyle the replay pane as a compact timeline/event stream and make the workspace layout constrain scroll to chat/replay panes.
+4. Add static regression tests and run focused/full verification.
+
+## Verification
+
+- `node --check src/agent_lab/presentation/static/app.js` passes.
+- `uv run pytest tests/test_websocket_api.py -q` passes (`36 passed`, one existing Starlette deprecation warning).
+- `uv run pytest` passes (`68 passed`, one existing Starlette deprecation warning).
+- `git diff --check` passes.
+- Browser smoke was attempted with Playwright, but the installed package has no local Chromium binary in this environment.

+ 22 - 0
docs/plans/todo-34-event-agent-history-boundary.md

@@ -0,0 +1,22 @@
+# Todo 34: EventAgent History Boundary
+
+## Status
+
+done
+
+## Goal
+
+Ensure EventAgent receives only conversational `user` and `assistant` messages, not the full ChatAgent prompt/context list or EventAgent/tool internal replies.
+
+## Plan
+
+1. Add a runtime helper that derives EventAgent history from the current conversation.
+2. Use it in both direct runtime and WebSocket session turn paths before enqueueing `EventAgentRequest`.
+3. Add regression tests proving system prompts, pre-messages with system role, and internal EventAgent replies are excluded from EventAgent history and audit.
+
+## Verification
+
+- `uv run pytest tests/test_debug_runtime.py -q` passes (`21 passed`).
+- `node --check src/agent_lab/presentation/static/app.js` passes.
+- `uv run pytest` passes (`70 passed`, one existing Starlette deprecation warning).
+- `git diff --check` passes.

+ 23 - 0
docs/plans/todo-35-audit-modal-raw-llm-events.md

@@ -0,0 +1,23 @@
+# Todo 35: Audit Modal, Round Event Metadata, Raw LLM Chunks
+
+## Status
+
+done
+
+## Goal
+
+Make audit replay usable in a larger modal, clarify per-round event metadata after EventAgent results trigger ChatAgent again, and expose raw LLM streaming chunks in audit details.
+
+## Plan
+
+1. Split configured available events from per-round event generation state in `chat_round_started` audit entries.
+2. Add raw chunk stream items from the OpenAI-compatible client and collect them into ChatAgent/EventAgent response audit records.
+3. Move Audit Replay into a modal with full-height event stream and detail panes.
+4. Add backend/frontend regression tests and run verification.
+
+## Verification
+
+- `node --check src/agent_lab/presentation/static/app.js` passes.
+- `uv run pytest tests/test_debug_runtime.py tests/test_event_agent.py tests/test_websocket_api.py -q` passes (`63 passed`, one existing Starlette deprecation warning).
+- `uv run pytest` passes (`71 passed`, one existing Starlette deprecation warning).
+- `git diff --check` passes.

+ 24 - 0
docs/plans/todo-36-audit-detail-compact-ui.md

@@ -0,0 +1,24 @@
+# Todo 36: Compact Audit Detail UI
+
+## Status
+
+done
+
+## Goal
+
+Fix the Audit Detail modal layout so selected events render as compact, readable detail rows instead of oversized pill-like blocks, and prevent stale CSS from keeping the old layout.
+
+## Plan
+
+1. Version the stylesheet URL to bust browser cache.
+2. Replace Audit Detail metadata pills with compact key/value fields.
+3. Render event-name lists as small code tags instead of large rounded blocks.
+4. Override global `section + section` spacing inside the audit modal/detail area.
+5. Add static regression checks and run verification.
+
+## Verification
+
+- `node --check src/agent_lab/presentation/static/app.js` passes.
+- `uv run pytest tests/test_websocket_api.py -q` passes (`36 passed`, one existing Starlette deprecation warning).
+- `uv run pytest` passes (`71 passed`, one existing Starlette deprecation warning).
+- `git diff --check` passes.

+ 25 - 0
docs/plans/todo-37-audit-detail-flow-layout.md

@@ -0,0 +1,25 @@
+# Todo 37: Audit Detail Flow Layout
+
+## Status
+
+done
+
+## Goal
+
+Fully replace the stretched Audit Detail card layout with a content-sized flow layout. Metadata must render as compact inline fields, not grid rows or large cards.
+
+## Plan
+
+1. Remove internal CSS Grid layout from `.audit-detail` and `.audit-detail-header`.
+2. Render metadata as compact inline definition fields.
+3. Keep detail sections in normal block flow with small spacing.
+4. Version static assets again to prevent stale browser CSS.
+5. Add tests that prevent `.audit-detail` and `.audit-detail-header` from returning to grid.
+
+## Verification
+
+- `node --check src/agent_lab/presentation/static/app.js` passes.
+- `uv run pytest tests/test_websocket_api.py -q` passes (`36 passed`, one existing Starlette deprecation warning).
+- `uv run pytest` passes (`71 passed`, one existing Starlette deprecation warning).
+- `git diff --check` passes.
+- Static regression asserts `.audit-detail` and `.audit-detail-header` do not use `display: grid`.

+ 22 - 0
docs/plans/todo-38-audit-round-event-semantics.md

@@ -0,0 +1,22 @@
+# Todo 38: Audit Round Event Semantics
+
+## Status
+
+done
+
+## Goal
+
+Make `chat_round_started.events_enabled` represent the events actually enabled for the current ChatAgent round. Preserve the full configured tool/event list separately so round 2 after EventAgent results is not misreported.
+
+## Plan
+
+1. Update runtime audit payloads so `events_enabled` is the per-round prompt/event generation list and `configured_events` is the full EventAgent configuration list.
+2. Update Audit Detail labels and summaries to distinguish configured events from current-round enabled events.
+3. Adjust regression tests for round 2 and keep existing raw LLM/audit modal coverage intact.
+
+## Verification
+
+- `node --check src/agent_lab/presentation/static/app.js` passes.
+- `uv run pytest tests/test_debug_runtime.py tests/test_websocket_api.py -q` passes (`58 passed`, one existing Starlette deprecation warning).
+- `uv run pytest` passes (`71 passed`, one existing Starlette deprecation warning).
+- `git diff --check` passes.

+ 19 - 0
docs/plans/todo-39-audit-turn-relative-time.md

@@ -0,0 +1,19 @@
+# Todo 39: Audit Turn-Relative Time
+
+## Goal
+
+Show every in-turn audit event with elapsed time relative to the user message that started the current turn.
+
+## Plan
+
+1. Add `turn_elapsed_ms` to audit details when an audit event belongs to a turn.
+2. Carry the turn start timestamp into EventAgent audit work so request/response events use the same baseline.
+3. Render the relative time in the audit event stream and detail header.
+
+## Verification
+
+- `uv run pytest tests/test_debug_runtime.py::test_runtime_audit_events_include_turn_relative_elapsed_time -q`
+- `uv run pytest tests/test_websocket_api.py::test_static_audit_replay_uses_event_stream_and_detail_panel -q`
+- `node --check src/agent_lab/presentation/static/app.js`
+- `uv run pytest`
+- `git diff --check`

+ 21 - 0
docs/plans/todo-40-chat-message-stream-audit.md

@@ -0,0 +1,21 @@
+# Todo 40: Chat Message Stream Audit Boundaries
+
+## Goal
+
+Add audit events around ChatAgent visible message streaming so replay can show when user-visible text starts and when it ends.
+
+## Plan
+
+1. Emit `chat_message_stream_started` before the first `message_delta` reaches the output queue.
+2. Emit `chat_message_stream_finished` after the model stream ends and before `chat_agent_response`.
+3. Keep `chat_agent_response` as the complete aggregated visible ChatAgent text for that call.
+4. Add readable Audit Replay summaries for the new events.
+
+## Verification
+
+- `uv run pytest tests/test_debug_runtime.py::test_runtime_audits_chat_message_stream_boundaries_in_output_order -q`
+- `uv run pytest tests/test_debug_runtime.py::test_runtime_emits_audit_events_and_backend_logs -q`
+- `uv run pytest tests/test_websocket_api.py::test_static_app_handles_audit_messages -q`
+- `uv run pytest`
+- `node --check src/agent_lab/presentation/static/app.js`
+- `git diff --check`

+ 18 - 0
docs/plans/todo-41-session-button-feedback.md

@@ -0,0 +1,18 @@
+# Todo 41: Session Button Feedback
+
+## Goal
+
+Make the Sessions panel respond visibly when users click New, Refresh, or Load.
+
+## Plan
+
+1. Add a local session status line inside the Sessions panel.
+2. Update session actions to set immediate in-panel feedback before requests.
+3. Keep the top-level connection status in sync for session actions.
+
+## Verification
+
+- `uv run pytest tests/test_websocket_api.py::test_static_session_ui_controls_and_replay_panels_are_available tests/test_websocket_api.py::test_static_app_loads_session_replay_and_usage -q`
+- `uv run pytest`
+- `node --check src/agent_lab/presentation/static/app.js`
+- `git diff --check`

+ 19 - 0
docs/plans/todo-42-session-switch-idle-socket.md

@@ -0,0 +1,19 @@
+# Todo 42: Session Switch Idle Socket
+
+## Goal
+
+Allow New and Load session actions after the current turn has completed, even when the reusable WebSocket session is still open.
+
+## Plan
+
+1. Add a focused static test that distinguishes active turn state from open WebSocket state.
+2. Track active turn state explicitly in the browser app.
+3. Guard session switching only during an active turn, and close any idle socket before changing sessions.
+
+## Verification
+
+- `uv run pytest tests/test_websocket_api.py::test_static_session_switch_guard_uses_active_turn_not_open_socket -q`
+- `uv run pytest tests/test_websocket_api.py -q`
+- `uv run pytest`
+- `node --check src/agent_lab/presentation/static/app.js`
+- `git diff --check`

+ 13 - 0
docs/plans/todos.md

@@ -56,3 +56,16 @@
 | 27 | done | `docs/plans/todo-27-event-agent-tool-arguments.md` | Make EventAgent tool argument generation robust for compatible providers that do not emit the expected tool-call finish reason or ignore tool calls. | `uv run pytest` passes (`53 passed`, one existing Starlette deprecation warning). |
 | 28 | done | `docs/plans/todo-28-session-turns.md` | Split WebSocket session lifetime from user turns so one conversation can run multiple user messages while each turn has its own event budget. | `uv run pytest` passes (`56 passed`, one existing Starlette deprecation warning). |
 | 29 | done | `docs/plans/todo-29-rich-audit-log.md` | Add audit entries for ChatAgent/EventAgent model parameters, prompts/messages, results, and usage without logging secrets. | `uv run pytest` passes (`57 passed`, one existing Starlette deprecation warning). |
+| 30 | done | `docs/plans/todo-30-sqlite-session-store.md` | Add a SQLite-backed store and REST APIs for sessions, chat messages, audit replay data, and usage summaries. | `uv run pytest tests/test_sqlite_store.py tests/test_websocket_api.py -q` passes (`36 passed`, one existing Starlette deprecation warning); `uv run pytest` passes (`66 passed`, one existing warning). |
+| 31 | done | `docs/plans/todo-31-runtime-persistence.md` | Persist runtime Agent config snapshots, user/assistant messages, audit entries, and per-call usage during WebSocket turns. | `uv run pytest tests/test_sqlite_store.py tests/test_debug_runtime.py tests/test_websocket_api.py -q` passes (`55 passed`, one existing warning); `uv run pytest` passes (`66 passed`, one existing warning). |
+| 32 | done | `docs/plans/todo-32-session-ui.md` | Add new-session/session-list UI, separate audit replay panel, and single-call/turn/session usage displays. | `node --check src/agent_lab/presentation/static/app.js`; `uv run pytest tests/test_websocket_api.py -q` passes (`34 passed`, one existing warning); `uv run pytest` passes (`66 passed`, one existing warning). |
+| 33 | done | `docs/plans/todo-33-audit-replay-stream-ui.md` | Redesign Audit Replay as a clickable event stream, keep audit events out of chat, and fix fixed/scrolling layout boundaries. | `node --check src/agent_lab/presentation/static/app.js`; `uv run pytest tests/test_websocket_api.py -q` passes (`36 passed`, one existing warning); `uv run pytest` passes (`68 passed`, one existing warning); `git diff --check` passes. |
+| 34 | done | `docs/plans/todo-34-event-agent-history-boundary.md` | Restrict EventAgent context to conversational user/assistant messages instead of the full ChatAgent context list. | `uv run pytest tests/test_debug_runtime.py -q` passes (`21 passed`); `node --check src/agent_lab/presentation/static/app.js`; `uv run pytest` passes (`70 passed`, one existing warning); `git diff --check` passes. |
+| 35 | done | `docs/plans/todo-35-audit-modal-raw-llm-events.md` | Make Audit Replay a modal, fix round event metadata after EventAgent follow-up rounds, and expose raw LLM response chunks. | `node --check src/agent_lab/presentation/static/app.js`; `uv run pytest tests/test_debug_runtime.py tests/test_event_agent.py tests/test_websocket_api.py -q` passes (`63 passed`, one existing warning); `uv run pytest` passes (`71 passed`, one existing warning); `git diff --check` passes. |
+| 36 | done | `docs/plans/todo-36-audit-detail-compact-ui.md` | Make Audit Detail modal metadata and event lists compact, and version CSS to avoid stale oversized styling. | `node --check src/agent_lab/presentation/static/app.js`; `uv run pytest tests/test_websocket_api.py -q` passes (`36 passed`, one existing warning); `uv run pytest` passes (`71 passed`, one existing warning); `git diff --check` passes. |
+| 37 | done | `docs/plans/todo-37-audit-detail-flow-layout.md` | Fully replace Audit Detail grid/card layout with compact flow layout so metadata cannot stretch vertically. | `node --check src/agent_lab/presentation/static/app.js`; `uv run pytest tests/test_websocket_api.py -q` passes (`36 passed`, one existing warning); `uv run pytest` passes (`71 passed`, one existing warning); `git diff --check` passes. |
+| 38 | done | `docs/plans/todo-38-audit-round-event-semantics.md` | Make `events_enabled` mean current-round enabled events and move full configured event list to `configured_events`. | `node --check src/agent_lab/presentation/static/app.js`; `uv run pytest tests/test_debug_runtime.py tests/test_websocket_api.py -q` passes (`58 passed`, one existing warning); `uv run pytest` passes (`71 passed`, one existing warning); `git diff --check` passes. |
+| 39 | done | `docs/plans/todo-39-audit-turn-relative-time.md` | Add per-audit-event elapsed time relative to the user message that started the current turn. | `uv run pytest` passes (`72 passed`, one existing Starlette deprecation warning); `node --check src/agent_lab/presentation/static/app.js`; `git diff --check` passes. |
+| 40 | done | `docs/plans/todo-40-chat-message-stream-audit.md` | Add audit events before ChatAgent visible message streaming starts and after it ends. | `uv run pytest` passes (`73 passed`, one existing Starlette deprecation warning); `node --check src/agent_lab/presentation/static/app.js`; `git diff --check` passes. |
+| 41 | done | `docs/plans/todo-41-session-button-feedback.md` | Add explicit Sessions-panel feedback for New, Refresh, and Load clicks. | `uv run pytest` passes (`73 passed`, one existing Starlette deprecation warning); `node --check src/agent_lab/presentation/static/app.js`; `git diff --check` passes. |
+| 42 | done | `docs/plans/todo-42-session-switch-idle-socket.md` | Allow switching sessions after a turn completes while the reusable WebSocket is still open. | `uv run pytest tests/test_websocket_api.py -q` passes (`37 passed`, one existing Starlette deprecation warning); `uv run pytest` passes (`74 passed`, one existing warning); `node --check src/agent_lab/presentation/static/app.js`; `git diff --check` passes. |

+ 1 - 0
src/agent_lab/application/contracts.py

@@ -35,6 +35,7 @@ class EventAgentParams(AgentParams):
 class DebugRunRequest(BaseModel):
     model_config = ConfigDict(extra="forbid")
 
+    session_id: str | None = None
     user_message: str
     system_prompts: list[str] = Field(default_factory=list)
     pre_messages: list[ChatMessage] = Field(default_factory=list)

+ 28 - 0
src/agent_lab/application/event_agent.py

@@ -30,6 +30,10 @@ class EventAgentRequest:
     history: list[ChatMessage]
     system_prompt: str = ""
     extra_body: dict[str, Any] | None = None
+    session_id: str | None = None
+    turn_index: int | None = None
+    round_index: int | None = None
+    turn_started_at: float | None = None
 
 
 class EventAgent:
@@ -44,6 +48,7 @@ class EventAgent:
         self.registry = registry or build_default_tool_registry()
         self.chat_client = chat_client
         self.params = params or AgentParams()
+        self._raw_chunks_by_event: dict[str, list[dict[str, Any]]] = {}
 
     async def handle(
         self,
@@ -52,6 +57,7 @@ class EventAgent:
         system_prompt: str = "",
         extra_body: dict[str, Any] | None = None,
     ) -> ChatMessage:
+        self._raw_chunks_by_event[event.id] = []
         if event.name not in self.enabled_tools:
             return self._tool_reply(
                 event,
@@ -103,6 +109,8 @@ class EventAgent:
         system_prompt: str = "",
         extra_body: dict[str, Any] | None = None,
     ) -> list[ChatMessage]:
+        for event in events:
+            self._raw_chunks_by_event[event.id] = []
         return await asyncio.gather(
             *[
                 self.handle(
@@ -115,6 +123,19 @@ class EventAgent:
             ]
         )
 
+    def raw_model_chunks(
+        self,
+        events: Sequence[ToolCallEvent],
+    ) -> list[dict[str, Any]]:
+        return [
+            {
+                "event_id": event.id,
+                "event_name": event.name,
+                "chunks": self._raw_chunks_by_event.get(event.id, []),
+            }
+            for event in events
+        ]
+
     async def _resolve_event_with_llm(
         self,
         event: ToolCallEvent,
@@ -135,12 +156,18 @@ class EventAgent:
                 ),
             }
         )
+        raw_chunks: list[dict[str, Any]] = []
         async for item in self.chat_client.stream_chat(
             messages=messages,
             tools=[tool],
             params=params,
         ):
+            if item.kind == "raw_chunk" and item.raw_chunk is not None:
+                raw_chunks.append(item.raw_chunk)
+                continue
+
             if item.kind == "event" and item.event is not None:
+                self._raw_chunks_by_event[event.id] = raw_chunks
                 return event.model_copy(
                     update={
                         "arguments": item.event.arguments,
@@ -148,6 +175,7 @@ class EventAgent:
                     }
                 )
 
+        self._raw_chunks_by_event[event.id] = raw_chunks
         return None
 
     def _build_argument_messages(

+ 289 - 20
src/agent_lab/application/runtime.py

@@ -7,9 +7,10 @@ from typing import Any, Protocol
 from agent_lab.application.contracts import AgentParams, DebugRunRequest
 from agent_lab.application.event_agent import EventAgent, EventAgentRequest
 from agent_lab.application.queues import RuntimeQueues
+from agent_lab.application.session_store import SessionStore
 from agent_lab.application.tools import ToolRegistry, build_default_tool_registry
 from agent_lab.domain.events import EVENT_BLOCK_START, ToolCallEvent
-from agent_lab.domain.messages import ChatMessage, StreamItem
+from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
 
 
 logger = logging.getLogger(__name__)
@@ -31,11 +32,13 @@ class DebugRuntime:
         chat_client: ChatClient,
         queues: RuntimeQueues | None = None,
         registry: ToolRegistry | None = None,
+        session_store: SessionStore | None = None,
         clock: Callable[[], float] = time.perf_counter,
     ) -> None:
         self.chat_client = chat_client
         self.queues = queues
         self.registry = registry or build_default_tool_registry()
+        self.session_store = session_store
         self.clock = clock
         self._tasks: list[asyncio.Task[None]] = []
 
@@ -125,10 +128,11 @@ class DebugRuntime:
         queues: RuntimeQueues,
     ) -> None:
         messages = self._build_initial_messages(request)
+        turn_started_at = self.clock()
         await queues.input.put(ChatMessage(role="user", content=request.user_message))
 
         await queues.output.put({"type": "session_started"})
-        await self._audit(queues, "session_started")
+        await self._audit(queues, "session_started", turn_started_at=turn_started_at)
 
         event_loops = 0
         round_index = 0
@@ -145,21 +149,30 @@ class DebugRuntime:
             assistant_content: list[str] = []
             events: list[ToolCallEvent] = []
             tool_replies: list[ChatMessage] = []
-            enabled_events = (
+            message_stream_started = False
+            message_delta_count = 0
+            configured_events = request.event_agent.enabled_tools
+            event_prompt_events = (
                 request.event_agent.enabled_tools
                 if event_loops < request.event_agent.max_event_loops
                 else []
             )
-            chat_messages = self._chat_messages_for_round(messages, enabled_events)
+            raw_chunks: list[dict[str, Any]] = []
+            chat_messages = self._chat_messages_for_round(messages, event_prompt_events)
             await self._audit(
                 queues,
                 "chat_round_started",
+                turn_started_at=turn_started_at,
                 round_index=round_index,
-                events_enabled=enabled_events,
+                events_enabled=event_prompt_events,
+                configured_events=configured_events,
+                event_generation_enabled=bool(event_prompt_events),
+                event_prompt_events=event_prompt_events,
             )
             await self._audit(
                 queues,
                 "chat_agent_request",
+                turn_started_at=turn_started_at,
                 agent="chat_agent",
                 round_index=round_index,
                 params=self._params_snapshot(request.chat_agent),
@@ -172,9 +185,24 @@ class DebugRuntime:
                 tools=[],
                 params=request.chat_agent,
             ):
+                if item.kind == "raw_chunk" and item.raw_chunk is not None:
+                    raw_chunks.append(item.raw_chunk)
+                    continue
+
                 if item.kind == "message_delta":
                     if ttft_ms is None:
                         ttft_ms = self._elapsed_ms(started_at)
+                    if not message_stream_started:
+                        message_stream_started = True
+                        await self._audit(
+                            queues,
+                            "chat_message_stream_started",
+                            turn_started_at=turn_started_at,
+                            agent="chat_agent",
+                            round_index=round_index,
+                            ttft_ms=ttft_ms,
+                        )
+                    message_delta_count += 1
                     assistant_content.append(item.content or "")
                     await queues.output.put(
                         {"type": "message_delta", "content": item.content}
@@ -201,14 +229,26 @@ class DebugRuntime:
                     await self._audit(
                         queues,
                         "chat_event_detected",
+                        turn_started_at=turn_started_at,
                         round_index=round_index,
                         event_id=event.id,
                         event_name=event.name,
                     )
 
+            if message_stream_started:
+                await self._audit(
+                    queues,
+                    "chat_message_stream_finished",
+                    turn_started_at=turn_started_at,
+                    agent="chat_agent",
+                    round_index=round_index,
+                    delta_count=message_delta_count,
+                    content_length=len("".join(assistant_content)),
+                )
             await self._audit(
                 queues,
                 "chat_agent_response",
+                turn_started_at=turn_started_at,
                 agent="chat_agent",
                 round_index=round_index,
                 content="".join(assistant_content),
@@ -219,6 +259,7 @@ class DebugRuntime:
                     "total_tokens": total_tokens,
                     "cached_tokens": cached_tokens,
                 },
+                raw_chunks=raw_chunks,
             )
 
             if assistant_content or events:
@@ -232,9 +273,10 @@ class DebugRuntime:
                 await queues.events.put(
                     EventAgentRequest(
                         events=events,
-                        history=list(messages),
+                        history=self._event_agent_history(messages),
                         system_prompt=request.event_agent.system_prompt,
                         extra_body=request.event_agent.extra_body,
+                        turn_started_at=turn_started_at,
                     )
                 )
                 tool_replies = await self._wait_for_tool_replies(
@@ -248,6 +290,7 @@ class DebugRuntime:
                 await self._audit(
                     queues,
                     "event_agent_completed",
+                    turn_started_at=turn_started_at,
                     round_index=round_index,
                     event_names=[event.name for event in events],
                     result_count=len(tool_replies),
@@ -271,6 +314,7 @@ class DebugRuntime:
             await self._audit(
                 queues,
                 "chat_round_finished",
+                turn_started_at=turn_started_at,
                 round_index=round_index,
                 had_event=saw_event,
                 elapsed_ms=elapsed_ms,
@@ -281,7 +325,12 @@ class DebugRuntime:
 
             event_loops += 1
 
-        await self._audit(queues, "session_finished", round_count=round_index)
+        await self._audit(
+            queues,
+            "session_finished",
+            turn_started_at=turn_started_at,
+            round_count=round_index,
+        )
         await queues.output.put({"type": "done"})
 
     async def _run_chat_session(
@@ -289,24 +338,39 @@ class DebugRuntime:
         request: DebugRunRequest,
         queues: RuntimeQueues,
     ) -> None:
+        session_id = self._ensure_persisted_session(request)
         messages = self._build_initial_messages(request)
-        await queues.output.put({"type": "session_started"})
-        await self._audit(queues, "session_started")
+        initial_turn_started_at = self.clock()
+        session_message = {"type": "session_started"}
+        if session_id is not None:
+            session_message["session_id"] = session_id
+        await queues.output.put(session_message)
+        await self._audit(
+            queues,
+            "session_started",
+            turn_started_at=initial_turn_started_at,
+            session_id=session_id,
+        )
         await queues.input.put(ChatMessage(role="user", content=request.user_message))
 
-        turn_index = 0
+        turn_index = self._initial_turn_index(session_id) - 1
+        next_turn_started_at: float | None = initial_turn_started_at
         while True:
             user_message = await queues.input.get()
             if user_message.role != "user" or user_message.name == "event_agent":
                 messages.append(user_message)
                 continue
             turn_index += 1
+            turn_started_at = next_turn_started_at or self.clock()
+            next_turn_started_at = None
             await self._run_chat_turn(
                 request,
                 queues,
                 messages,
                 user_message,
                 turn_index,
+                session_id,
+                turn_started_at,
             )
 
     async def _run_chat_turn(
@@ -316,10 +380,20 @@ class DebugRuntime:
         messages: list[ChatMessage],
         user_message: ChatMessage,
         turn_index: int,
+        session_id: str | None = None,
+        turn_started_at: float | None = None,
     ) -> None:
+        turn_started_at = turn_started_at or self.clock()
         messages.append(user_message)
+        self._start_persisted_turn(session_id, turn_index, user_message)
         await queues.output.put({"type": "turn_started", "turn_index": turn_index})
-        await self._audit(queues, "turn_started", turn_index=turn_index)
+        await self._audit(
+            queues,
+            "turn_started",
+            turn_started_at=turn_started_at,
+            session_id=session_id,
+            turn_index=turn_index,
+        )
 
         event_loops = 0
         round_index = 0
@@ -341,22 +415,33 @@ class DebugRuntime:
             saw_event = False
             assistant_content: list[str] = []
             events: list[ToolCallEvent] = []
-            enabled_events = (
+            message_stream_started = False
+            message_delta_count = 0
+            configured_events = request.event_agent.enabled_tools
+            event_prompt_events = (
                 request.event_agent.enabled_tools
                 if event_loops < request.event_agent.max_event_loops
                 else []
             )
-            chat_messages = self._chat_messages_for_round(messages, enabled_events)
+            raw_chunks: list[dict[str, Any]] = []
+            chat_messages = self._chat_messages_for_round(messages, event_prompt_events)
             await self._audit(
                 queues,
                 "chat_round_started",
+                turn_started_at=turn_started_at,
+                session_id=session_id,
                 turn_index=turn_index,
                 round_index=round_index,
-                events_enabled=enabled_events,
+                events_enabled=event_prompt_events,
+                configured_events=configured_events,
+                event_generation_enabled=bool(event_prompt_events),
+                event_prompt_events=event_prompt_events,
             )
             await self._audit(
                 queues,
                 "chat_agent_request",
+                turn_started_at=turn_started_at,
+                session_id=session_id,
                 agent="chat_agent",
                 turn_index=turn_index,
                 round_index=round_index,
@@ -370,9 +455,26 @@ class DebugRuntime:
                 tools=[],
                 params=request.chat_agent,
             ):
+                if item.kind == "raw_chunk" and item.raw_chunk is not None:
+                    raw_chunks.append(item.raw_chunk)
+                    continue
+
                 if item.kind == "message_delta":
                     if ttft_ms is None:
                         ttft_ms = self._elapsed_ms(started_at)
+                    if not message_stream_started:
+                        message_stream_started = True
+                        await self._audit(
+                            queues,
+                            "chat_message_stream_started",
+                            turn_started_at=turn_started_at,
+                            session_id=session_id,
+                            agent="chat_agent",
+                            turn_index=turn_index,
+                            round_index=round_index,
+                            ttft_ms=ttft_ms,
+                        )
+                    message_delta_count += 1
                     assistant_content.append(item.content or "")
                     await queues.output.put(
                         {"type": "message_delta", "content": item.content}
@@ -399,15 +501,31 @@ class DebugRuntime:
                     await self._audit(
                         queues,
                         "chat_event_detected",
+                        turn_started_at=turn_started_at,
+                        session_id=session_id,
                         turn_index=turn_index,
                         round_index=round_index,
                         event_id=event.id,
                         event_name=event.name,
                     )
 
+            if message_stream_started:
+                await self._audit(
+                    queues,
+                    "chat_message_stream_finished",
+                    turn_started_at=turn_started_at,
+                    session_id=session_id,
+                    agent="chat_agent",
+                    turn_index=turn_index,
+                    round_index=round_index,
+                    delta_count=message_delta_count,
+                    content_length=len("".join(assistant_content)),
+                )
             await self._audit(
                 queues,
                 "chat_agent_response",
+                turn_started_at=turn_started_at,
+                session_id=session_id,
                 agent="chat_agent",
                 turn_index=turn_index,
                 round_index=round_index,
@@ -419,23 +537,32 @@ class DebugRuntime:
                     "total_tokens": total_tokens,
                     "cached_tokens": cached_tokens,
                 },
+                raw_chunks=raw_chunks,
             )
 
             if assistant_content or events:
-                messages.append(
-                    ChatMessage(
-                        role="assistant",
-                        content="".join(assistant_content),
-                    )
+                assistant_message = ChatMessage(
+                    role="assistant",
+                    content="".join(assistant_content),
+                )
+                messages.append(assistant_message)
+                self._append_persisted_message(
+                    session_id,
+                    turn_index,
+                    assistant_message,
                 )
 
             if events:
                 await queues.events.put(
                     EventAgentRequest(
                         events=events,
-                        history=list(messages),
+                        history=self._event_agent_history(messages),
                         system_prompt=request.event_agent.system_prompt,
                         extra_body=request.event_agent.extra_body,
+                        session_id=session_id,
+                        turn_index=turn_index,
+                        round_index=round_index,
+                        turn_started_at=turn_started_at,
                     )
                 )
                 tool_replies = await self._wait_for_tool_replies(
@@ -449,6 +576,8 @@ class DebugRuntime:
                 await self._audit(
                     queues,
                     "event_agent_completed",
+                    turn_started_at=turn_started_at,
+                    session_id=session_id,
                     turn_index=turn_index,
                     round_index=round_index,
                     event_names=[event.name for event in events],
@@ -457,6 +586,19 @@ class DebugRuntime:
                 )
 
             elapsed_ms = self._elapsed_ms(started_at)
+            self._append_persisted_usage(
+                session_id,
+                turn_index,
+                round_index,
+                usage=TokenUsage(
+                    prompt_tokens=prompt_tokens,
+                    completion_tokens=completion_tokens,
+                    total_tokens=total_tokens,
+                    cached_tokens=cached_tokens,
+                ),
+                ttft_ms=ttft_ms,
+                elapsed_ms=elapsed_ms,
+            )
             await queues.output.put(
                 {
                     "type": "round_stats",
@@ -473,6 +615,8 @@ class DebugRuntime:
             await self._audit(
                 queues,
                 "chat_round_finished",
+                turn_started_at=turn_started_at,
+                session_id=session_id,
                 turn_index=turn_index,
                 round_index=round_index,
                 had_event=saw_event,
@@ -489,9 +633,12 @@ class DebugRuntime:
         await self._audit(
             queues,
             "turn_completed",
+            turn_started_at=turn_started_at,
+            session_id=session_id,
             turn_index=turn_index,
             round_count=round_index,
         )
+        self._complete_persisted_turn(session_id, turn_index)
         await queues.output.put(
             {
                 "type": "turn_completed",
@@ -512,7 +659,11 @@ class DebugRuntime:
             await self._audit(
                 queues,
                 "event_agent_request",
+                turn_started_at=request.turn_started_at,
+                session_id=request.session_id,
                 agent="event_agent",
+                turn_index=request.turn_index,
+                round_index=request.round_index,
                 params=self._params_snapshot(event_agent.params),
                 events=self._event_snapshots(request.events),
                 history=self._message_snapshots(request.history),
@@ -536,8 +687,13 @@ class DebugRuntime:
             await self._audit(
                 queues,
                 "event_agent_response",
+                turn_started_at=request.turn_started_at,
+                session_id=request.session_id,
                 agent="event_agent",
+                turn_index=request.turn_index,
+                round_index=request.round_index,
                 replies=[reply.model_dump() for reply in replies],
+                raw_model_chunks=event_agent.raw_model_chunks(request.events),
             )
             for reply in replies:
                 await queues.input.put(reply)
@@ -609,6 +765,13 @@ class DebugRuntime:
             for message in messages
         )
 
+    def _event_agent_history(self, messages: list[ChatMessage]) -> list[ChatMessage]:
+        return [
+            message
+            for message in messages
+            if message.role in {"user", "assistant"} and message.name != "event_agent"
+        ]
+
     async def _drain_input(
         self,
         queues: RuntimeQueues,
@@ -629,6 +792,97 @@ class DebugRuntime:
     def _elapsed_ms(self, started_at: float) -> int:
         return round((self.clock() - started_at) * 1000)
 
+    def _ensure_persisted_session(self, request: DebugRunRequest) -> str | None:
+        if self.session_store is None:
+            return None
+        return self.session_store.ensure_session(
+            request.session_id,
+            title=self._session_title(request.user_message),
+            config=self._session_config_snapshot(request),
+        )
+
+    def _initial_turn_index(self, session_id: str | None) -> int:
+        if self.session_store is None or session_id is None:
+            return 1
+        return self.session_store.next_turn_index(session_id)
+
+    def _start_persisted_turn(
+        self,
+        session_id: str | None,
+        turn_index: int,
+        user_message: ChatMessage,
+    ) -> None:
+        if self.session_store is None or session_id is None:
+            return
+        self.session_store.start_turn(
+            session_id,
+            turn_index=turn_index,
+            user_message=user_message.content,
+        )
+        self.session_store.append_message(
+            session_id,
+            turn_index=turn_index,
+            message=user_message,
+        )
+
+    def _complete_persisted_turn(
+        self,
+        session_id: str | None,
+        turn_index: int,
+    ) -> None:
+        if self.session_store is None or session_id is None:
+            return
+        self.session_store.complete_turn(session_id, turn_index=turn_index)
+
+    def _append_persisted_message(
+        self,
+        session_id: str | None,
+        turn_index: int,
+        message: ChatMessage,
+    ) -> None:
+        if self.session_store is None or session_id is None:
+            return
+        self.session_store.append_message(
+            session_id,
+            turn_index=turn_index,
+            message=message,
+        )
+
+    def _append_persisted_usage(
+        self,
+        session_id: str | None,
+        turn_index: int,
+        round_index: int,
+        *,
+        usage: TokenUsage,
+        ttft_ms: int | None,
+        elapsed_ms: int,
+    ) -> None:
+        if self.session_store is None or session_id is None:
+            return
+        self.session_store.append_usage(
+            session_id,
+            turn_index=turn_index,
+            round_index=round_index,
+            usage=usage,
+            ttft_ms=ttft_ms,
+            elapsed_ms=elapsed_ms,
+        )
+
+    def _session_title(self, user_message: str) -> str:
+        title = " ".join(user_message.split())
+        if not title:
+            return "Debug session"
+        return title[:80]
+
+    def _session_config_snapshot(self, request: DebugRunRequest) -> dict[str, Any]:
+        return {
+            "system_prompts": list(request.system_prompts),
+            "pre_messages": self._message_snapshots(request.pre_messages),
+            "chat_agent": request.chat_agent.model_dump(),
+            "event_agent": request.event_agent.model_dump(),
+        }
+
     def _params_snapshot(self, params: AgentParams) -> dict[str, Any]:
         return params.model_dump()
 
@@ -642,9 +896,24 @@ class DebugRuntime:
         self,
         queues: RuntimeQueues,
         event: str,
+        *,
+        session_id: str | None = None,
+        turn_started_at: float | None = None,
         **details: Any,
     ) -> None:
+        if turn_started_at is not None:
+            details["turn_elapsed_ms"] = max(0, self._elapsed_ms(turn_started_at))
         logger.info("audit event=%s details=%s", event, details)
+        if self.session_store is not None and session_id is not None:
+            turn_index = details.get("turn_index")
+            round_index = details.get("round_index")
+            self.session_store.append_audit(
+                session_id,
+                event=event,
+                details=details,
+                turn_index=turn_index if isinstance(turn_index, int) else None,
+                round_index=round_index if isinstance(round_index, int) else None,
+            )
         await queues.output.put(
             {
                 "type": "audit",

+ 85 - 0
src/agent_lab/application/session_store.py

@@ -0,0 +1,85 @@
+from typing import Any, Protocol
+
+from agent_lab.domain.messages import ChatMessage, TokenUsage
+
+
+class SessionStore(Protocol):
+    def create_session(
+        self,
+        *,
+        title: str,
+        config: dict[str, Any],
+        session_id: str | None = None,
+    ) -> str:
+        ...
+
+    def ensure_session(
+        self,
+        session_id: str | None,
+        *,
+        title: str,
+        config: dict[str, Any],
+    ) -> str:
+        ...
+
+    def list_sessions(self) -> list[dict[str, Any]]:
+        ...
+
+    def get_session(self, session_id: str) -> dict[str, Any] | None:
+        ...
+
+    def next_turn_index(self, session_id: str) -> int:
+        ...
+
+    def start_turn(
+        self,
+        session_id: str,
+        *,
+        turn_index: int,
+        user_message: str,
+    ) -> None:
+        ...
+
+    def complete_turn(self, session_id: str, *, turn_index: int) -> None:
+        ...
+
+    def append_message(
+        self,
+        session_id: str,
+        *,
+        turn_index: int,
+        message: ChatMessage,
+    ) -> None:
+        ...
+
+    def append_audit(
+        self,
+        session_id: str,
+        *,
+        event: str,
+        details: dict[str, Any],
+        turn_index: int | None = None,
+        round_index: int | None = None,
+    ) -> None:
+        ...
+
+    def append_usage(
+        self,
+        session_id: str,
+        *,
+        turn_index: int,
+        round_index: int,
+        usage: TokenUsage,
+        ttft_ms: int | None,
+        elapsed_ms: int,
+    ) -> None:
+        ...
+
+    def list_messages(self, session_id: str) -> list[dict[str, Any]]:
+        ...
+
+    def list_audit_logs(self, session_id: str) -> list[dict[str, Any]]:
+        ...
+
+    def usage_summary(self, session_id: str) -> dict[str, Any]:
+        ...

+ 6 - 1
src/agent_lab/domain/messages.py

@@ -1,5 +1,5 @@
 from dataclasses import dataclass
-from typing import Literal
+from typing import Any, Literal
 
 from pydantic import BaseModel, ConfigDict
 
@@ -30,6 +30,7 @@ class StreamItem:
     content: str | None = None
     event: ToolCallEvent | None = None
     usage: TokenUsage | None = None
+    raw_chunk: dict[str, Any] | None = None
 
     @classmethod
     def message_delta(cls, content: str) -> "StreamItem":
@@ -39,6 +40,10 @@ class StreamItem:
     def usage_item(cls, usage: TokenUsage) -> "StreamItem":
         return cls(kind="usage", usage=usage)
 
+    @classmethod
+    def raw_response_chunk(cls, chunk: dict[str, Any]) -> "StreamItem":
+        return cls(kind="raw_chunk", raw_chunk=chunk)
+
 
 def _stream_event(cls: type[StreamItem], event: ToolCallEvent) -> StreamItem:
     return cls(kind="event", event=event)

+ 1 - 0
src/agent_lab/infrastructure/chat_client.py

@@ -75,6 +75,7 @@ class OpenAICompatibleChatClient:
                     break
 
                 chunk = json.loads(data)
+                yield StreamItem.raw_response_chunk(chunk)
                 for item in parser.feed(chunk):
                     yield item
 

+ 484 - 0
src/agent_lab/infrastructure/sqlite_store.py

@@ -0,0 +1,484 @@
+import json
+import sqlite3
+import threading
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+from agent_lab.domain.messages import ChatMessage, TokenUsage
+
+
+class SQLiteSessionStore:
+    def __init__(self, database_path: str | Path) -> None:
+        self.database_path = str(database_path)
+        self._lock = threading.Lock()
+        self._connection: sqlite3.Connection | None = None
+
+    def create_session(
+        self,
+        *,
+        title: str,
+        config: dict[str, Any],
+        session_id: str | None = None,
+    ) -> str:
+        session_id = session_id or uuid4().hex
+        now = self._now()
+        with self._lock:
+            self._connect().execute(
+                """
+                INSERT INTO sessions (id, title, created_at, updated_at, config_json)
+                VALUES (?, ?, ?, ?, ?)
+                """,
+                (session_id, title, now, now, self._dump(config)),
+            )
+            self._connect().commit()
+        return session_id
+
+    def ensure_session(
+        self,
+        session_id: str | None,
+        *,
+        title: str,
+        config: dict[str, Any],
+    ) -> str:
+        if session_id and self.get_session(session_id) is not None:
+            self.update_session_config(session_id, config=config)
+            return session_id
+        return self.create_session(title=title, config=config, session_id=session_id)
+
+    def update_session_config(self, session_id: str, *, config: dict[str, Any]) -> None:
+        now = self._now()
+        with self._lock:
+            self._connect().execute(
+                """
+                UPDATE sessions
+                SET config_json = ?, updated_at = ?
+                WHERE id = ?
+                """,
+                (self._dump(config), now, session_id),
+            )
+            self._connect().commit()
+
+    def list_sessions(self) -> list[dict[str, Any]]:
+        rows = self._fetchall(
+            """
+            SELECT
+                sessions.id,
+                sessions.title,
+                sessions.created_at,
+                sessions.updated_at,
+                COALESCE(turn_counts.turn_count, 0) AS turn_count,
+                COALESCE(usage_totals.total_tokens, 0) AS total_tokens
+            FROM sessions
+            LEFT JOIN (
+                SELECT session_id, COUNT(*) AS turn_count
+                FROM turns
+                GROUP BY session_id
+            ) AS turn_counts ON turn_counts.session_id = sessions.id
+            LEFT JOIN (
+                SELECT session_id, SUM(total_tokens) AS total_tokens
+                FROM usage_stats
+                GROUP BY session_id
+            ) AS usage_totals ON usage_totals.session_id = sessions.id
+            ORDER BY sessions.updated_at DESC, sessions.created_at DESC
+            """
+        )
+        return [
+            {
+                "id": row["id"],
+                "title": row["title"],
+                "created_at": row["created_at"],
+                "updated_at": row["updated_at"],
+                "turn_count": row["turn_count"],
+                "total_tokens": row["total_tokens"],
+            }
+            for row in rows
+        ]
+
+    def get_session(self, session_id: str) -> dict[str, Any] | None:
+        row = self._fetchone(
+            """
+            SELECT id, title, created_at, updated_at, config_json
+            FROM sessions
+            WHERE id = ?
+            """,
+            (session_id,),
+        )
+        if row is None:
+            return None
+        return {
+            "id": row["id"],
+            "title": row["title"],
+            "created_at": row["created_at"],
+            "updated_at": row["updated_at"],
+            "config": self._load(row["config_json"]),
+        }
+
+    def next_turn_index(self, session_id: str) -> int:
+        row = self._fetchone(
+            """
+            SELECT COALESCE(MAX(turn_index), 0) + 1 AS next_turn_index
+            FROM turns
+            WHERE session_id = ?
+            """,
+            (session_id,),
+        )
+        assert row is not None
+        return row["next_turn_index"]
+
+    def start_turn(
+        self,
+        session_id: str,
+        *,
+        turn_index: int,
+        user_message: str,
+    ) -> None:
+        now = self._now()
+        with self._lock:
+            self._connect().execute(
+                """
+                INSERT OR REPLACE INTO turns
+                    (session_id, turn_index, user_message, started_at, completed_at)
+                VALUES (?, ?, ?, ?, NULL)
+                """,
+                (session_id, turn_index, user_message, now),
+            )
+            self._touch_session_locked(session_id, now)
+            self._connect().commit()
+
+    def complete_turn(self, session_id: str, *, turn_index: int) -> None:
+        now = self._now()
+        with self._lock:
+            self._connect().execute(
+                """
+                UPDATE turns
+                SET completed_at = ?
+                WHERE session_id = ? AND turn_index = ?
+                """,
+                (now, session_id, turn_index),
+            )
+            self._touch_session_locked(session_id, now)
+            self._connect().commit()
+
+    def append_message(
+        self,
+        session_id: str,
+        *,
+        turn_index: int,
+        message: ChatMessage,
+    ) -> None:
+        now = self._now()
+        with self._lock:
+            self._connect().execute(
+                """
+                INSERT INTO messages
+                    (session_id, turn_index, role, content, name, tool_call_id, created_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?)
+                """,
+                (
+                    session_id,
+                    turn_index,
+                    message.role,
+                    message.content,
+                    message.name,
+                    message.tool_call_id,
+                    now,
+                ),
+            )
+            self._touch_session_locked(session_id, now)
+            self._connect().commit()
+
+    def append_audit(
+        self,
+        session_id: str,
+        *,
+        event: str,
+        details: dict[str, Any],
+        turn_index: int | None = None,
+        round_index: int | None = None,
+    ) -> None:
+        now = self._now()
+        with self._lock:
+            self._connect().execute(
+                """
+                INSERT INTO audit_logs
+                    (session_id, turn_index, round_index, event, details_json, created_at)
+                VALUES (?, ?, ?, ?, ?, ?)
+                """,
+                (
+                    session_id,
+                    turn_index,
+                    round_index,
+                    event,
+                    self._dump(details),
+                    now,
+                ),
+            )
+            self._touch_session_locked(session_id, now)
+            self._connect().commit()
+
+    def append_usage(
+        self,
+        session_id: str,
+        *,
+        turn_index: int,
+        round_index: int,
+        usage: TokenUsage,
+        ttft_ms: int | None,
+        elapsed_ms: int,
+    ) -> None:
+        now = self._now()
+        with self._lock:
+            self._connect().execute(
+                """
+                INSERT INTO usage_stats
+                    (
+                        session_id, turn_index, round_index, prompt_tokens,
+                        completion_tokens, total_tokens, cached_tokens,
+                        ttft_ms, elapsed_ms, created_at
+                    )
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+                """,
+                (
+                    session_id,
+                    turn_index,
+                    round_index,
+                    usage.prompt_tokens,
+                    usage.completion_tokens,
+                    usage.total_tokens,
+                    usage.cached_tokens,
+                    ttft_ms,
+                    elapsed_ms,
+                    now,
+                ),
+            )
+            self._touch_session_locked(session_id, now)
+            self._connect().commit()
+
+    def list_messages(self, session_id: str) -> list[dict[str, Any]]:
+        rows = self._fetchall(
+            """
+            SELECT id, turn_index, role, content, name, tool_call_id, created_at
+            FROM messages
+            WHERE session_id = ?
+            ORDER BY id ASC
+            """,
+            (session_id,),
+        )
+        return [
+            {
+                "id": row["id"],
+                "turn_index": row["turn_index"],
+                "role": row["role"],
+                "content": row["content"],
+                "name": row["name"],
+                "tool_call_id": row["tool_call_id"],
+                "created_at": row["created_at"],
+            }
+            for row in rows
+        ]
+
+    def list_audit_logs(self, session_id: str) -> list[dict[str, Any]]:
+        rows = self._fetchall(
+            """
+            SELECT id, turn_index, round_index, event, details_json, created_at
+            FROM audit_logs
+            WHERE session_id = ?
+            ORDER BY id ASC
+            """,
+            (session_id,),
+        )
+        return [
+            {
+                "id": row["id"],
+                "turn_index": row["turn_index"],
+                "round_index": row["round_index"],
+                "event": row["event"],
+                "details": self._load(row["details_json"]),
+                "created_at": row["created_at"],
+            }
+            for row in rows
+        ]
+
+    def usage_summary(self, session_id: str) -> dict[str, Any]:
+        call_rows = self._fetchall(
+            """
+            SELECT
+                id, turn_index, round_index, prompt_tokens, completion_tokens,
+                total_tokens, cached_tokens, ttft_ms, elapsed_ms, created_at
+            FROM usage_stats
+            WHERE session_id = ?
+            ORDER BY id ASC
+            """,
+            (session_id,),
+        )
+        calls = [self._usage_row(row) for row in call_rows]
+        turn_rows = self._fetchall(
+            """
+            SELECT
+                turn_index,
+                COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
+                COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
+                COALESCE(SUM(total_tokens), 0) AS total_tokens,
+                COALESCE(SUM(cached_tokens), 0) AS cached_tokens,
+                COALESCE(SUM(elapsed_ms), 0) AS elapsed_ms
+            FROM usage_stats
+            WHERE session_id = ?
+            GROUP BY turn_index
+            ORDER BY turn_index ASC
+            """,
+            (session_id,),
+        )
+        turns = [
+            {
+                "turn_index": row["turn_index"],
+                "prompt_tokens": row["prompt_tokens"],
+                "completion_tokens": row["completion_tokens"],
+                "total_tokens": row["total_tokens"],
+                "cached_tokens": row["cached_tokens"],
+                "elapsed_ms": row["elapsed_ms"],
+            }
+            for row in turn_rows
+        ]
+        session = self._fetchone(
+            """
+            SELECT
+                COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
+                COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
+                COALESCE(SUM(total_tokens), 0) AS total_tokens,
+                COALESCE(SUM(cached_tokens), 0) AS cached_tokens,
+                COALESCE(SUM(elapsed_ms), 0) AS elapsed_ms
+            FROM usage_stats
+            WHERE session_id = ?
+            """,
+            (session_id,),
+        )
+        return {
+            "calls": calls,
+            "turns": turns,
+            "session": {
+                "prompt_tokens": session["prompt_tokens"],
+                "completion_tokens": session["completion_tokens"],
+                "total_tokens": session["total_tokens"],
+                "cached_tokens": session["cached_tokens"],
+                "elapsed_ms": session["elapsed_ms"],
+            },
+        }
+
+    def _usage_row(self, row: sqlite3.Row) -> dict[str, Any]:
+        return {
+            "id": row["id"],
+            "turn_index": row["turn_index"],
+            "round_index": row["round_index"],
+            "prompt_tokens": row["prompt_tokens"],
+            "completion_tokens": row["completion_tokens"],
+            "total_tokens": row["total_tokens"],
+            "cached_tokens": row["cached_tokens"],
+            "ttft_ms": row["ttft_ms"],
+            "elapsed_ms": row["elapsed_ms"],
+            "created_at": row["created_at"],
+        }
+
+    def _fetchone(
+        self,
+        sql: str,
+        params: tuple[Any, ...] = (),
+    ) -> sqlite3.Row | None:
+        with self._lock:
+            return self._connect().execute(sql, params).fetchone()
+
+    def _fetchall(
+        self,
+        sql: str,
+        params: tuple[Any, ...] = (),
+    ) -> list[sqlite3.Row]:
+        with self._lock:
+            return list(self._connect().execute(sql, params).fetchall())
+
+    def _connect(self) -> sqlite3.Connection:
+        if self._connection is None:
+            if self.database_path != ":memory:":
+                Path(self.database_path).parent.mkdir(parents=True, exist_ok=True)
+            self._connection = sqlite3.connect(
+                self.database_path,
+                check_same_thread=False,
+            )
+            self._connection.row_factory = sqlite3.Row
+            self._create_tables()
+        return self._connection
+
+    def _create_tables(self) -> None:
+        assert self._connection is not None
+        self._connection.executescript(
+            """
+            CREATE TABLE IF NOT EXISTS sessions (
+                id TEXT PRIMARY KEY,
+                title TEXT NOT NULL,
+                created_at TEXT NOT NULL,
+                updated_at TEXT NOT NULL,
+                config_json TEXT NOT NULL
+            );
+
+            CREATE TABLE IF NOT EXISTS turns (
+                session_id TEXT NOT NULL,
+                turn_index INTEGER NOT NULL,
+                user_message TEXT NOT NULL,
+                started_at TEXT NOT NULL,
+                completed_at TEXT,
+                PRIMARY KEY (session_id, turn_index)
+            );
+
+            CREATE TABLE IF NOT EXISTS messages (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                session_id TEXT NOT NULL,
+                turn_index INTEGER NOT NULL,
+                role TEXT NOT NULL,
+                content TEXT NOT NULL,
+                name TEXT,
+                tool_call_id TEXT,
+                created_at TEXT NOT NULL
+            );
+
+            CREATE TABLE IF NOT EXISTS audit_logs (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                session_id TEXT NOT NULL,
+                turn_index INTEGER,
+                round_index INTEGER,
+                event TEXT NOT NULL,
+                details_json TEXT NOT NULL,
+                created_at TEXT NOT NULL
+            );
+
+            CREATE TABLE IF NOT EXISTS usage_stats (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                session_id TEXT NOT NULL,
+                turn_index INTEGER NOT NULL,
+                round_index INTEGER NOT NULL,
+                prompt_tokens INTEGER NOT NULL,
+                completion_tokens INTEGER NOT NULL,
+                total_tokens INTEGER NOT NULL,
+                cached_tokens INTEGER NOT NULL,
+                ttft_ms INTEGER,
+                elapsed_ms INTEGER NOT NULL,
+                created_at TEXT NOT NULL
+            );
+            """
+        )
+        self._connection.commit()
+
+    def _touch_session_locked(self, session_id: str, now: str) -> None:
+        self._connect().execute(
+            "UPDATE sessions SET updated_at = ? WHERE id = ?",
+            (now, session_id),
+        )
+
+    def _now(self) -> str:
+        return datetime.now(UTC).isoformat(timespec="milliseconds")
+
+    def _dump(self, value: dict[str, Any]) -> str:
+        return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
+
+    def _load(self, value: str) -> dict[str, Any]:
+        loaded = json.loads(value)
+        return loaded if isinstance(loaded, dict) else {}

+ 740 - 15
src/agent_lab/presentation/static/app.js

@@ -3,6 +3,16 @@ const statusEl = document.querySelector("#connection-status");
 const chatForm = document.querySelector("#chat-form");
 const userMessage = document.querySelector("#user-message");
 const runButton = document.querySelector("#run-button");
+const sessionTitle = document.querySelector("#session-title");
+const sessionList = document.querySelector("#session-list");
+const sessionStatus = document.querySelector("#session-status");
+const auditReplay = document.querySelector("#audit-replay");
+const auditCount = document.querySelector("#audit-count");
+const auditSummaryEl = document.querySelector("#audit-summary");
+const auditDetail = document.querySelector("#audit-detail");
+const sessionUsageSummary = document.querySelector("#session-usage-summary");
+const sessionUsageTurns = document.querySelector("#session-usage-turns");
+const sessionUsageCalls = document.querySelector("#session-usage-calls");
 const snapshotName = document.querySelector("#workspace-snapshot-name");
 const savedSnapshots = document.querySelector("#saved-workspace-snapshots");
 const systemPrompts = document.querySelector("#system-prompts");
@@ -12,6 +22,7 @@ const toolList = document.querySelector("#tool-list");
 const toolCount = document.querySelector("#tool-count");
 const chatConfigDialog = document.querySelector("#chat-config-dialog");
 const eventConfigDialog = document.querySelector("#event-config-dialog");
+const auditDialog = document.querySelector("#audit-dialog");
 const WORKSPACE_SNAPSHOTS_STORAGE_KEY = "agent-lab.workspace-snapshots.v1";
 const LEGACY_PROMPT_SETS_STORAGE_KEY = "agent-lab.prompt-sets.v1";
 
@@ -23,6 +34,12 @@ let elapsedTimer = 0;
 let hasBackendRoundStats = false;
 let pendingEnabledTools = null;
 let availableTools = [];
+let currentSessionId = null;
+let sessions = [];
+let auditEntries = [];
+let selectedAuditKey = null;
+let liveAuditSequence = 0;
+let turnActive = false;
 
 bindClick("#add-system-prompt", () => {
   systemPrompts.append(createSystemPrompt({ content: "" }));
@@ -35,6 +52,19 @@ bindClick("#add-pre-message", () => {
 bindClick("#save-workspace-snapshot", saveWorkspaceSnapshot);
 bindClick("#load-workspace-snapshot", loadWorkspaceSnapshot);
 bindClick("#delete-workspace-snapshot", deleteWorkspaceSnapshot);
+bindClick("#new-session", createSession);
+bindClick("#refresh-sessions", () => {
+  loadSessions();
+});
+bindClick("#load-session", () => {
+  loadSelectedSession();
+});
+bindClick("#open-audit-dialog", () => {
+  auditDialog.showModal();
+});
+bindClick("#close-audit-dialog", () => {
+  auditDialog.close();
+});
 bindClick("#open-chat-config-panel", openChatConfig);
 bindClick("#close-chat-config", () => {
   chatConfigDialog.close();
@@ -58,6 +88,9 @@ chatForm.addEventListener("submit", (event) => {
 initializePromptList();
 refreshWorkspaceSnapshotSelector();
 loadTools();
+loadSessions({ preserveStatus: true });
+renderAuditReplay([]);
+renderSessionUsage({ calls: [], turns: [], session: {} });
 
 function openChatConfig() {
   chatConfigDialog.showModal();
@@ -86,6 +119,125 @@ async function loadTools() {
   }
 }
 
+async function loadSessions(options = {}) {
+  if (!options.preserveStatus) {
+    setSessionStatus("Refreshing sessions");
+  }
+  try {
+    sessions = await fetchJson("/api/sessions");
+    renderSessions();
+    if (!options.preserveStatus) {
+      setSessionStatus("Sessions refreshed");
+    }
+  } catch (error) {
+    if (!options.preserveStatus) {
+      setSessionStatus("Failed to load sessions");
+    }
+  }
+}
+
+function renderSessions() {
+  const previous = currentSessionId || sessionList.value;
+  sessionList.textContent = "";
+
+  const placeholder = document.createElement("option");
+  placeholder.value = "";
+  placeholder.textContent = sessions.length ? "Select a session" : "No saved sessions";
+  sessionList.append(placeholder);
+
+  sessions.forEach((session) => {
+    const option = document.createElement("option");
+    option.value = session.id;
+    option.textContent = `${session.title} (${session.turn_count} turns, ${session.total_tokens} tokens)`;
+    sessionList.append(option);
+  });
+
+  if (previous && sessions.some((session) => session.id === previous)) {
+    sessionList.value = previous;
+  }
+}
+
+async function createSession() {
+  if (hasActiveTurn()) {
+    setSessionStatus("Finish current turn before switching sessions");
+    return;
+  }
+  closeSocket();
+  setSessionStatus("Creating session");
+  try {
+    const title = sessionTitle.value.trim() || "New session";
+    const session = await fetchJson("/api/sessions", {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        title,
+        config: buildWorkspaceSnapshot(),
+      }),
+    });
+    currentSessionId = session.id;
+    sessionTitle.value = session.title;
+    await loadSessions({ preserveStatus: true });
+    await loadSessionReplay(session.id, { preserveStatus: true });
+    setSessionStatus("Session created");
+  } catch (error) {
+    setSessionStatus("Failed to create session");
+  }
+}
+
+async function loadSelectedSession() {
+  if (hasActiveTurn()) {
+    setSessionStatus("Finish current turn before switching sessions");
+    return;
+  }
+  const sessionId = sessionList.value;
+  if (!sessionId) {
+    setSessionStatus("Select a session");
+    return;
+  }
+  closeSocket();
+  setSessionStatus("Loading session");
+  currentSessionId = sessionId;
+  await loadSessionReplay(sessionId);
+}
+
+async function loadSessionReplay(sessionId, options = {}) {
+  try {
+    const [messages, audit, usage] = await Promise.all([
+      fetchJson(`/api/sessions/${sessionId}/messages`),
+      fetchJson(`/api/sessions/${sessionId}/audit`),
+      fetchJson(`/api/sessions/${sessionId}/usage`),
+    ]);
+    renderReplayMessages(messages);
+    renderAuditReplay(audit);
+    renderSessionUsage(usage);
+    const session = sessions.find((item) => item.id === sessionId);
+    if (session) {
+      sessionTitle.value = session.title;
+      sessionList.value = sessionId;
+    }
+    if (!options.preserveStatus && !isSocketOpen()) {
+      setSessionStatus("Session loaded");
+    }
+  } catch (error) {
+    if (!options.preserveStatus) {
+      setSessionStatus("Failed to load session");
+    }
+  }
+}
+
+function setSessionStatus(message) {
+  sessionStatus.textContent = message;
+  statusEl.textContent = message;
+}
+
+async function fetchJson(url, options) {
+  const response = await fetch(url, options);
+  if (!response.ok) {
+    throw new Error(`Request failed: ${response.status}`);
+  }
+  return response.json();
+}
+
 function renderTools(tools) {
   availableTools = tools;
   toolList.textContent = "";
@@ -622,21 +774,35 @@ function runDebugSession() {
   resetRun();
   beginTurn(submittedMessage, "Connecting");
   socket = new WebSocket(wsUrl());
-  socket.addEventListener("open", () => {
+  const activeSocket = socket;
+  activeSocket.addEventListener("open", () => {
+    if (socket !== activeSocket) {
+      return;
+    }
     statusEl.textContent = "Waiting for first token";
-    socket.send(JSON.stringify(buildRequest(submittedMessage)));
+    activeSocket.send(JSON.stringify(buildRequest(submittedMessage)));
     userMessage.value = "";
   });
-  socket.addEventListener("message", (event) => {
+  activeSocket.addEventListener("message", (event) => {
+    if (socket !== activeSocket) {
+      return;
+    }
     handleServerMessage(JSON.parse(event.data));
   });
-  socket.addEventListener("close", () => {
+  activeSocket.addEventListener("close", () => {
+    if (socket !== activeSocket) {
+      return;
+    }
+    socket = null;
     statusEl.textContent = "Idle";
     stopElapsedTimer();
     setRunState(false);
     updateElapsed();
   });
-  socket.addEventListener("error", () => {
+  activeSocket.addEventListener("error", () => {
+    if (socket !== activeSocket) {
+      return;
+    }
     appendLog("error", "WebSocket connection failed");
   });
 }
@@ -645,6 +811,10 @@ function isSocketOpen() {
   return socket && socket.readyState === WebSocket.OPEN;
 }
 
+function hasActiveTurn() {
+  return turnActive;
+}
+
 function beginTurn(submittedMessage, statusText) {
   activeAssistant = null;
   firstTokenAt = 0;
@@ -659,6 +829,7 @@ function beginTurn(submittedMessage, statusText) {
 
 function buildRequest(submittedMessage = userMessage.value) {
   return {
+    session_id: currentSessionId || undefined,
     user_message: submittedMessage,
     system_prompts: collectSystemPrompts(),
     pre_messages: [...document.querySelectorAll(".pre-message")]
@@ -694,6 +865,10 @@ function selectedTools() {
 
 function handleServerMessage(message) {
   if (message.type === "session_started") {
+    if (message.session_id) {
+      currentSessionId = message.session_id;
+      loadSessions({ preserveStatus: true });
+    }
     appendLog("session", "Session started");
     return;
   }
@@ -714,7 +889,7 @@ function handleServerMessage(message) {
     return;
   }
   if (message.type === "audit") {
-    appendLog("audit", formatAuditMessage(message));
+    appendAuditEntry(message);
     return;
   }
   if (message.type === "event") {
@@ -737,6 +912,10 @@ function handleServerMessage(message) {
     stopElapsedTimer();
     setRunState(false);
     updateElapsed();
+    if (currentSessionId) {
+      loadSessions({ preserveStatus: true });
+      loadSessionReplay(currentSessionId, { preserveStatus: true });
+    }
     return;
   }
   if (message.type === "done") {
@@ -744,17 +923,14 @@ function handleServerMessage(message) {
     statusEl.textContent = "Idle";
     stopElapsedTimer();
     setRunState(false);
+    if (currentSessionId) {
+      loadSessions({ preserveStatus: true });
+      loadSessionReplay(currentSessionId, { preserveStatus: true });
+    }
     closeSocket();
   }
 }
 
-function formatAuditMessage(message) {
-  const details = message.details && Object.keys(message.details).length
-    ? ` ${JSON.stringify(message.details)}`
-    : "";
-  return `${message.event}${details}`;
-}
-
 function appendAssistantDelta(content) {
   if (!firstTokenAt) {
     firstTokenAt = performance.now();
@@ -781,6 +957,550 @@ function appendLog(kind, content) {
   messagesEl.scrollTop = messagesEl.scrollHeight;
 }
 
+function renderReplayMessages(messages) {
+  messagesEl.textContent = "";
+  activeAssistant = null;
+  if (!messages.length) {
+    appendLog("session", "No messages yet");
+    return;
+  }
+
+  messages.forEach((message) => {
+    const item = document.createElement("div");
+    const kind = ["user", "assistant", "tool"].includes(message.role)
+      ? message.role
+      : "session";
+    item.className = `message ${kind}`;
+    item.textContent = message.content;
+    messagesEl.append(item);
+  });
+  messagesEl.scrollTop = messagesEl.scrollHeight;
+}
+
+function appendAuditEntry(message) {
+  const entry = normalizeAuditEntry(message);
+  renderAuditReplay([...auditEntries, entry], {
+    selectedKey: auditEntryKey(entry),
+    scrollToEnd: true,
+  });
+}
+
+function normalizeAuditEntry(entry) {
+  const details = entry.details || {};
+  return {
+    id: entry.id || `live-${++liveAuditSequence}`,
+    event: entry.event || "unknown_event",
+    details,
+    turn_index: entry.turn_index ?? details.turn_index ?? null,
+    round_index: entry.round_index ?? details.round_index ?? null,
+    turn_elapsed_ms: entry.turn_elapsed_ms ?? details.turn_elapsed_ms ?? null,
+    created_at: entry.created_at || new Date().toISOString(),
+  };
+}
+
+function auditEntryKey(entry) {
+  return String(entry.id);
+}
+
+function renderAuditReplay(entries, options = {}) {
+  auditEntries = entries.map(normalizeAuditEntry);
+  auditReplay.textContent = "";
+  auditCount.textContent = `${auditEntries.length} events`;
+
+  if (!auditEntries.length) {
+    selectedAuditKey = null;
+    auditSummaryEl.textContent = "No audit entries";
+    auditReplay.append(emptyReplayRow("No audit entries"));
+    renderAuditDetail(null);
+    return;
+  }
+
+  const keys = auditEntries.map(auditEntryKey);
+  selectedAuditKey = options.selectedKey || selectedAuditKey;
+  if (!selectedAuditKey || !keys.includes(selectedAuditKey)) {
+    selectedAuditKey = keys[keys.length - 1];
+  }
+
+  auditEntries.forEach((entry) => {
+    const key = auditEntryKey(entry);
+    const row = document.createElement("button");
+    row.type = "button";
+    row.className = `audit-event ${auditKindClass(entry.event)}`;
+    row.dataset.auditKey = key;
+    if (key === selectedAuditKey) {
+      row.classList.add("is-selected");
+    }
+    row.addEventListener("click", () => {
+      selectAuditEntry(key);
+    });
+
+    const marker = document.createElement("span");
+    marker.className = "audit-marker";
+
+    const body = document.createElement("span");
+    body.className = "audit-event-body";
+
+    const meta = document.createElement("span");
+    meta.className = "audit-event-meta";
+    meta.textContent = auditMeta(entry);
+
+    const title = document.createElement("strong");
+    title.textContent = auditTitle(entry.event);
+
+    const summary = document.createElement("span");
+    summary.className = "audit-event-summary";
+    summary.textContent = auditSummary(entry);
+
+    body.append(meta, title, summary);
+    row.append(marker, body);
+    auditReplay.append(row);
+  });
+
+  renderAuditDetail(
+    auditEntries.find((entry) => auditEntryKey(entry) === selectedAuditKey) || null,
+  );
+  const selectedEntry = auditEntries.find(
+    (entry) => auditEntryKey(entry) === selectedAuditKey,
+  );
+  auditSummaryEl.textContent = selectedEntry
+    ? `${auditTitle(selectedEntry.event)} · ${auditMeta(selectedEntry)}`
+    : `${auditEntries.length} audit events`;
+
+  if (options.scrollToEnd) {
+    auditReplay.scrollTop = auditReplay.scrollHeight;
+  }
+}
+
+function selectAuditEntry(key) {
+  selectedAuditKey = key;
+  renderAuditReplay(auditEntries);
+}
+
+function renderAuditDetail(entry) {
+  auditDetail.textContent = "";
+  if (!entry) {
+    auditDetail.append(emptyReplayRow("Select an audit event to inspect details"));
+    return;
+  }
+
+  const header = document.createElement("div");
+  header.className = "audit-detail-header";
+  const title = document.createElement("h3");
+  title.className = "audit-detail-title";
+  title.textContent = auditTitle(entry.event);
+  const meta = document.createElement("dl");
+  meta.className = "audit-detail-meta";
+  appendMetaField(meta, "Turn / Round", auditTurnRound(entry));
+  appendMetaField(meta, "Since User Message", auditRelativeTime(entry));
+  appendMetaField(meta, "Time", formatTime(entry.created_at) || "-");
+  appendMetaField(meta, "Agent", auditKindLabel(entry.event));
+  header.append(title, meta);
+  auditDetail.append(header);
+
+  const details = entry.details || {};
+  if (entry.event === "chat_agent_request" || entry.event === "event_agent_request") {
+    appendKeyValueSection(auditDetail, "Model Parameters", details.params);
+    appendMessagesSection(auditDetail, details.messages ? "Messages" : "History", details.messages || details.history);
+    appendEventsSection(auditDetail, details.events);
+    appendToolsSection(auditDetail, details.tools);
+    appendKeyValueSection(auditDetail, "Extra Body", details.extra_body);
+  } else if (entry.event === "chat_agent_response") {
+    appendTextSection(auditDetail, "Assistant Text", details.content);
+    appendListSection(auditDetail, "Events", details.event_names);
+    appendKeyValueSection(auditDetail, "Usage", details.usage);
+    appendRawChunksSection(auditDetail, "Raw LLM Chunks", details.raw_chunks);
+  } else if (entry.event === "event_agent_response") {
+    appendRepliesSection(auditDetail, details.replies);
+    appendRawModelChunksSection(auditDetail, details.raw_model_chunks);
+  } else if (entry.event === "chat_event_detected") {
+    appendKeyValueSection(auditDetail, "Detected Event", {
+      name: details.event_name,
+      id: details.event_id,
+    });
+  } else if (entry.event === "event_agent_completed") {
+    appendListSection(auditDetail, "Completed Events", details.event_names);
+    appendKeyValueSection(auditDetail, "Results", {
+      result_count: details.result_count,
+    });
+    appendTextSection(auditDetail, "Tool Result Summary", details.result_summary);
+  } else if (entry.event === "chat_message_stream_started" || entry.event === "chat_message_stream_finished") {
+    appendKeyValueSection(auditDetail, "Message Stream", {
+      ttft_ms: details.ttft_ms,
+      delta_count: details.delta_count,
+      content_length: details.content_length,
+    });
+  } else if (entry.event === "chat_round_started") {
+    appendKeyValueSection(auditDetail, "Event State", {
+      event_generation_enabled: details.event_generation_enabled,
+    });
+    appendListSection(auditDetail, "Enabled This Round", details.events_enabled);
+    appendListSection(auditDetail, "Configured Events", details.configured_events);
+  } else if (entry.event === "chat_round_finished") {
+    appendKeyValueSection(auditDetail, "Round Result", {
+      had_event: details.had_event,
+      elapsed_ms: details.elapsed_ms,
+    });
+  } else if (entry.event === "turn_completed") {
+    appendKeyValueSection(auditDetail, "Turn Result", {
+      round_count: details.round_count,
+    });
+  } else if (details.raw_chunks) {
+    appendRawChunksSection(auditDetail, "Raw LLM Chunks", details.raw_chunks);
+  } else if (details.raw_model_chunks) {
+    appendRawModelChunksSection(auditDetail, details.raw_model_chunks);
+  } else {
+    appendKeyValueSection(auditDetail, "Details", details);
+  }
+
+  appendRawDetails(auditDetail, details);
+}
+
+function auditTitle(event) {
+  return event
+    .split("_")
+    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+    .join(" ");
+}
+
+function auditMeta(entry) {
+  const time = formatTime(entry.created_at);
+  const relativeTime = auditRelativeTime(entry);
+  return `${auditTurnRound(entry)} / ${relativeTime}${time ? ` / ${time}` : ""}`;
+}
+
+function auditTurnRound(entry) {
+  const turn = entry.turn_index === null || entry.turn_index === undefined ? "-" : entry.turn_index;
+  const round = entry.round_index === null || entry.round_index === undefined ? "-" : entry.round_index;
+  return `T${turn} / R${round}`;
+}
+
+function auditRelativeTime(entry) {
+  if (entry.turn_elapsed_ms === null || entry.turn_elapsed_ms === undefined) {
+    return "-";
+  }
+  return `+${formatMs(entry.turn_elapsed_ms)}`;
+}
+
+function appendMetaField(container, label, value) {
+  const item = document.createElement("div");
+  const term = document.createElement("dt");
+  term.textContent = label;
+  const description = document.createElement("dd");
+  description.textContent = value;
+  item.append(term, description);
+  container.append(item);
+}
+
+function auditSummary(entry) {
+  const details = entry.details || {};
+  if (entry.event === "chat_agent_request") {
+    return `${details.params?.model || "model default"} · ${details.messages?.length || 0} messages`;
+  }
+  if (entry.event === "event_agent_request") {
+    return `${details.params?.model || "model default"} · ${(details.events || []).map((event) => event.name).join(", ") || "no events"}`;
+  }
+  if (entry.event === "chat_agent_response") {
+    const rawCount = details.raw_chunks?.length || 0;
+    return `${details.usage?.total_tokens || 0} tokens · ${rawCount} raw chunks · ${(details.event_names || []).join(", ") || "no events"}`;
+  }
+  if (entry.event === "event_agent_response") {
+    const rawCount = (details.raw_model_chunks || []).reduce(
+      (count, item) => count + (item.chunks?.length || 0),
+      0,
+    );
+    return `${details.replies?.length || 0} tool replies · ${rawCount} raw chunks`;
+  }
+  if (entry.event === "chat_event_detected") {
+    return details.event_name || "event detected";
+  }
+  if (entry.event === "event_agent_completed") {
+    return `${details.result_count || 0} results · ${(details.event_names || []).join(", ")}`;
+  }
+  if (entry.event === "chat_message_stream_started") {
+    return `visible text started · TTFT ${formatMs(details.ttft_ms)}`;
+  }
+  if (entry.event === "chat_message_stream_finished") {
+    return `${details.delta_count || 0} deltas · ${details.content_length || 0} chars`;
+  }
+  if (entry.event === "chat_round_started") {
+    const state = details.event_generation_enabled ? "event generation on" : "event generation off";
+    return `${details.events_enabled?.length || 0} enabled this round · ${state}`;
+  }
+  if (entry.event === "chat_round_finished") {
+    return `${formatMs(details.elapsed_ms)} · ${details.had_event ? "event" : "no event"}`;
+  }
+  if (entry.event === "turn_completed") {
+    return `${details.round_count || 0} rounds`;
+  }
+  return JSON.stringify(details).slice(0, 120);
+}
+
+function auditKindClass(event) {
+  if (event.includes("request")) {
+    return "audit-kind-request";
+  }
+  if (event.includes("response") || event.includes("completed")) {
+    return "audit-kind-response";
+  }
+  if (event.includes("event_agent")) {
+    return "audit-kind-event-agent";
+  }
+  if (event.includes("chat")) {
+    return "audit-kind-chat";
+  }
+  return "audit-kind-lifecycle";
+}
+
+function auditKindLabel(event) {
+  if (event.includes("event_agent")) {
+    return "EventAgent";
+  }
+  if (event.includes("chat_agent") || event.includes("chat_round") || event.includes("chat_event") || event.includes("chat_message")) {
+    return "ChatAgent";
+  }
+  return "Lifecycle";
+}
+
+function appendKeyValueSection(container, title, data) {
+  if (!data || !Object.keys(data).length) {
+    return;
+  }
+  const grid = document.createElement("div");
+  grid.className = "detail-kv";
+  Object.entries(data).forEach(([key, value]) => {
+    if (value === null || value === undefined || value === "") {
+      return;
+    }
+    const label = document.createElement("span");
+    label.textContent = key;
+    const content = document.createElement("strong");
+    content.textContent = typeof value === "object" ? JSON.stringify(value) : String(value);
+    grid.append(label, content);
+  });
+  if (grid.children.length) {
+    appendDetailSection(container, title, grid);
+  }
+}
+
+function appendRawChunksSection(container, title, chunks) {
+  if (!chunks || !chunks.length) {
+    return;
+  }
+  const list = document.createElement("div");
+  list.className = "detail-list";
+  chunks.forEach((chunk, index) => {
+    const item = document.createElement("div");
+    item.className = "detail-message raw-chunk";
+    const label = document.createElement("span");
+    label.textContent = `chunk ${index + 1}`;
+    const content = document.createElement("pre");
+    content.textContent = JSON.stringify(chunk, null, 2);
+    item.append(label, content);
+    list.append(item);
+  });
+  appendDetailSection(container, title, list);
+}
+
+function appendRawModelChunksSection(container, rawGroups) {
+  if (!rawGroups || !rawGroups.length) {
+    return;
+  }
+  const list = document.createElement("div");
+  list.className = "detail-list";
+  rawGroups.forEach((group) => {
+    const item = document.createElement("div");
+    item.className = "detail-message raw-chunk";
+    const label = document.createElement("span");
+    label.textContent = `${group.event_name || "event"} · ${group.event_id || "-"}`;
+    const content = document.createElement("pre");
+    content.textContent = JSON.stringify(group.chunks || [], null, 2);
+    item.append(label, content);
+    list.append(item);
+  });
+  appendDetailSection(container, "Raw EventAgent LLM Chunks", list);
+}
+
+function appendMessagesSection(container, title, messages) {
+  if (!messages || !messages.length) {
+    return;
+  }
+  const list = document.createElement("div");
+  list.className = "detail-list";
+  messages.forEach((message) => {
+    const item = document.createElement("div");
+    item.className = "detail-message";
+    const role = document.createElement("span");
+    role.textContent = message.role || "message";
+    const content = document.createElement("pre");
+    content.textContent = message.content || "";
+    item.append(role, content);
+    list.append(item);
+  });
+  appendDetailSection(container, title, list);
+}
+
+function appendToolsSection(container, tools) {
+  if (!tools || !tools.length) {
+    return;
+  }
+  const list = document.createElement("div");
+  list.className = "detail-list";
+  tools.forEach((tool) => {
+    const item = document.createElement("div");
+    item.className = "detail-message";
+    const name = document.createElement("span");
+    name.textContent = tool.function?.name || "tool";
+    const description = document.createElement("p");
+    description.textContent = tool.function?.description || "No description";
+    item.append(name, description);
+    list.append(item);
+  });
+  appendDetailSection(container, "Tools", list);
+}
+
+function appendEventsSection(container, events) {
+  if (!events || !events.length) {
+    return;
+  }
+  const values = events.map((event) => `${event.name}${event.id ? ` (${event.id})` : ""}`);
+  appendListSection(container, "Events", values);
+}
+
+function appendRepliesSection(container, replies) {
+  if (!replies || !replies.length) {
+    return;
+  }
+  const list = document.createElement("div");
+  list.className = "detail-list";
+  replies.forEach((reply) => {
+    const item = document.createElement("div");
+    item.className = "detail-message";
+    const role = document.createElement("span");
+    role.textContent = reply.role || "reply";
+    const content = document.createElement("pre");
+    content.textContent = reply.content || "";
+    item.append(role, content);
+    list.append(item);
+  });
+  appendDetailSection(container, "Tool Replies", list);
+}
+
+function appendListSection(container, title, items) {
+  if (!items || !items.length) {
+    return;
+  }
+  const list = document.createElement("ul");
+  list.className = "detail-tags";
+  items.forEach((item) => {
+    const tag = document.createElement("li");
+    const code = document.createElement("code");
+    code.textContent = String(item);
+    tag.append(code);
+    list.append(tag);
+  });
+  appendDetailSection(container, title, list);
+}
+
+function appendTextSection(container, title, value) {
+  if (!value) {
+    return;
+  }
+  const pre = document.createElement("pre");
+  pre.textContent = String(value);
+  appendDetailSection(container, title, pre);
+}
+
+function appendDetailSection(container, title, content) {
+  const section = document.createElement("section");
+  section.className = "audit-detail-block";
+  const heading = document.createElement("h3");
+  heading.className = "audit-detail-block-title";
+  heading.textContent = title;
+  section.append(heading, content);
+  container.append(section);
+}
+
+function appendRawDetails(container, details) {
+  if (!details || !Object.keys(details).length) {
+    return;
+  }
+  const raw = document.createElement("details");
+  raw.className = "raw-detail";
+  const summary = document.createElement("summary");
+  summary.textContent = "Raw JSON";
+  const pre = document.createElement("pre");
+  pre.textContent = JSON.stringify(details, null, 2);
+  raw.append(summary, pre);
+  container.append(raw);
+}
+
+function formatTime(value) {
+  if (!value) {
+    return "";
+  }
+  const date = new Date(value);
+  if (Number.isNaN(date.getTime())) {
+    return value;
+  }
+  return date.toLocaleTimeString([], {
+    hour: "2-digit",
+    minute: "2-digit",
+    second: "2-digit",
+  });
+}
+
+function renderSessionUsage(usage) {
+  const session = usage.session || {};
+  sessionUsageSummary.textContent = "";
+  [
+    ["Tokens", session.total_tokens || 0],
+    ["Cached", session.cached_tokens || 0],
+    ["Prompt", session.prompt_tokens || 0],
+    ["Elapsed", formatMs(session.elapsed_ms || 0)],
+  ].forEach(([label, value]) => {
+    const item = document.createElement("div");
+    const labelEl = document.createElement("span");
+    labelEl.textContent = label;
+    const valueEl = document.createElement("strong");
+    valueEl.textContent = value;
+    item.append(labelEl, valueEl);
+    sessionUsageSummary.append(item);
+  });
+
+  renderUsageRows(
+    sessionUsageTurns,
+    usage.turns || [],
+    (turn) => `Turn ${turn.turn_index}: ${turn.total_tokens} tokens, ${turn.elapsed_ms}ms`,
+  );
+  renderUsageRows(
+    sessionUsageCalls,
+    usage.calls || [],
+    (call) => `T${call.turn_index} R${call.round_index}: ${call.total_tokens} tokens, TTFT ${formatMs(call.ttft_ms)}, elapsed ${formatMs(call.elapsed_ms)}`,
+  );
+}
+
+function renderUsageRows(container, rows, formatter) {
+  container.textContent = "";
+  if (!rows.length) {
+    container.append(emptyReplayRow("No usage rows"));
+    return;
+  }
+
+  rows.forEach((row) => {
+    const item = document.createElement("div");
+    item.className = "replay-row";
+    item.textContent = formatter(row);
+    container.append(item);
+  });
+}
+
+function emptyReplayRow(content) {
+  const row = document.createElement("div");
+  row.className = "replay-row";
+  row.textContent = content;
+  return row;
+}
+
 function updateUsage(usage) {
   if (hasBackendRoundStats) {
     return;
@@ -829,6 +1549,7 @@ function stopElapsedTimer() {
 }
 
 function setRunState(isRunning) {
+  turnActive = isRunning;
   runButton.disabled = isRunning;
 }
 
@@ -850,8 +1571,12 @@ function resetTurnStats() {
 }
 
 function closeSocket() {
-  if (socket && socket.readyState < WebSocket.CLOSING) {
-    socket.close();
+  const closingSocket = socket;
+  if (closingSocket && closingSocket.readyState < WebSocket.CLOSING) {
+    closingSocket.close();
+  }
+  if (socket === closingSocket) {
+    socket = null;
   }
 }
 

+ 71 - 3
src/agent_lab/presentation/static/index.html

@@ -4,7 +4,7 @@
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <title>Agent Lab</title>
-    <link rel="stylesheet" href="/static/styles.css" />
+    <link rel="stylesheet" href="/static/styles.css?v=20260707-session-switch-idle-socket" />
   </head>
   <body>
     <header class="topbar">
@@ -16,6 +16,23 @@
 
     <main class="layout">
       <aside class="panel controls">
+        <section class="session-section">
+          <div class="section-head">
+            <h2>Sessions</h2>
+            <button id="refresh-sessions" type="button">Refresh</button>
+          </div>
+          <label>Title <input id="session-title" placeholder="debug session" /></label>
+          <label>
+            Saved Session
+            <select id="session-list"></select>
+          </label>
+          <div class="button-row two">
+            <button id="new-session" type="button">New</button>
+            <button id="load-session" type="button">Load</button>
+          </div>
+          <div id="session-status" class="session-status">Ready</div>
+        </section>
+
         <section class="tools-section">
           <div class="section-head">
             <h2>Tools <span id="tool-count">0 selected</span></h2>
@@ -52,7 +69,33 @@
       </aside>
 
       <section class="workspace">
-        <div id="messages" class="messages"></div>
+        <div class="workspace-body">
+          <div id="messages" class="messages"></div>
+          <aside class="replay-panel">
+            <section class="audit-launch-section">
+              <div class="section-head">
+                <h2>Audit Replay</h2>
+                <span id="audit-count" class="meta-count">0 entries</span>
+              </div>
+              <p id="audit-summary" class="audit-summary">No audit entries</p>
+              <button id="open-audit-dialog" type="button">Open Audit Replay</button>
+            </section>
+            <section class="session-usage-section">
+              <h2>Session Usage</h2>
+              <div id="session-usage-summary" class="usage-summary"></div>
+              <div class="usage-groups">
+                <div>
+                  <h3>Turns</h3>
+                  <div id="session-usage-turns" class="replay-list compact"></div>
+                </div>
+                <div>
+                  <h3>Calls</h3>
+                  <div id="session-usage-calls" class="replay-list compact"></div>
+                </div>
+              </div>
+            </section>
+          </aside>
+        </div>
 
         <div class="stats">
           <div><span>Tokens</span><strong id="stat-tokens">0</strong></div>
@@ -68,6 +111,31 @@
       </section>
     </main>
 
+    <dialog id="audit-dialog" class="modal audit-modal">
+      <form method="dialog" class="modal-shell">
+        <div class="modal-head">
+          <h2>Audit Replay</h2>
+          <button id="close-audit-dialog" type="button">Close</button>
+        </div>
+        <div class="audit-modal-body">
+          <section class="audit-replay-section">
+            <div class="section-head">
+              <h2>Event Stream</h2>
+              <span class="meta-count">click event for details</span>
+            </div>
+            <div id="audit-replay" class="audit-stream"></div>
+          </section>
+          <section class="audit-detail-section">
+            <div class="section-head">
+              <h2>Audit Detail</h2>
+              <span class="meta-count">selected event</span>
+            </div>
+            <div id="audit-detail" class="audit-detail"></div>
+          </section>
+        </div>
+      </form>
+    </dialog>
+
     <dialog id="chat-config-dialog" class="modal">
       <form method="dialog" class="modal-shell">
         <div class="modal-head">
@@ -164,6 +232,6 @@
       </div>
     </template>
 
-    <script src="/static/app.js?v=20260706-workspace-snapshot"></script>
+    <script src="/static/app.js?v=20260707-session-switch-idle-socket"></script>
   </body>
 </html>

+ 441 - 0
src/agent_lab/presentation/static/styles.css

@@ -9,8 +9,10 @@
 }
 
 body {
+  height: 100vh;
   margin: 0;
   min-height: 100vh;
+  overflow: hidden;
 }
 
 button,
@@ -75,6 +77,8 @@ button:disabled {
   gap: 16px;
   grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
   height: calc(100vh - 56px);
+  min-height: 0;
+  overflow: hidden;
   padding: 16px;
 }
 
@@ -86,6 +90,7 @@ button:disabled {
 }
 
 .controls {
+  min-height: 0;
   overflow: auto;
   padding: 16px;
 }
@@ -128,6 +133,21 @@ h2 {
   grid-template-columns: repeat(2, minmax(0, 1fr));
 }
 
+.session-section .section-head button {
+  background: #eef2f5;
+  border: 1px solid #cbd5df;
+  color: #1f2933;
+  font-size: 12px;
+  padding: 5px 8px;
+}
+
+.session-status {
+  color: #52616f;
+  font-size: 12px;
+  margin-top: 8px;
+  min-height: 16px;
+}
+
 label {
   display: grid;
   gap: 6px;
@@ -256,17 +276,81 @@ textarea {
 .workspace {
   display: grid;
   grid-template-rows: minmax(0, 1fr) auto auto;
+  min-height: 0;
   min-width: 0;
+  overflow: hidden;
+}
+
+.workspace-body {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
+  min-height: 0;
+  overflow: hidden;
 }
 
 .messages {
   display: flex;
   flex-direction: column;
   gap: 10px;
+  min-height: 0;
   overflow: auto;
   padding: 16px;
 }
 
+.replay-panel {
+  border-left: 1px solid #e4e9ee;
+  display: grid;
+  gap: 16px;
+  grid-template-rows: auto minmax(0, 1fr);
+  min-height: 0;
+  overflow: hidden;
+  padding: 16px;
+}
+
+.audit-launch-section {
+  display: grid;
+  gap: 10px;
+}
+
+.audit-launch-section button {
+  width: 100%;
+}
+
+.audit-summary {
+  background: #f7f9fb;
+  border: 1px solid #e4e9ee;
+  border-radius: 6px;
+  color: #52616f;
+  font-size: 12px;
+  line-height: 1.45;
+  margin: 0;
+  padding: 8px;
+  word-break: break-word;
+}
+
+.audit-replay-section,
+.audit-detail-section {
+  display: grid;
+  grid-template-rows: auto minmax(0, 1fr);
+  min-height: 0;
+}
+
+.audit-modal-body section + section {
+  border-top: 0;
+  margin-top: 0;
+  padding-top: 0;
+}
+
+.session-usage-section {
+  min-height: 0;
+  overflow: auto;
+}
+
+.meta-count {
+  color: #697586;
+  font-size: 12px;
+}
+
 .message {
   border-radius: 8px;
   line-height: 1.5;
@@ -309,6 +393,321 @@ textarea {
   font-size: 13px;
 }
 
+.replay-list,
+.audit-stream {
+  display: grid;
+  gap: 8px;
+  margin-top: 10px;
+}
+
+.audit-stream {
+  align-content: start;
+  overflow: auto;
+  padding-right: 2px;
+}
+
+.replay-list.compact {
+  gap: 6px;
+}
+
+.replay-row {
+  background: #f7f9fb;
+  border: 1px solid #e4e9ee;
+  border-radius: 6px;
+  color: #1f2933;
+  font-size: 12px;
+  line-height: 1.45;
+  padding: 8px;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.audit-event {
+  align-items: stretch;
+  background: #ffffff;
+  border: 1px solid #d7dee5;
+  border-radius: 6px;
+  color: #1f2933;
+  display: grid;
+  gap: 8px;
+  grid-template-columns: 14px minmax(0, 1fr);
+  min-height: 62px;
+  padding: 8px;
+  text-align: left;
+  width: 100%;
+}
+
+.audit-event:hover,
+.audit-event.is-selected {
+  background: #f7f9fb;
+  border-color: #94a3b8;
+}
+
+.audit-event.is-selected {
+  box-shadow: inset 3px 0 0 #0f766e;
+}
+
+.audit-marker {
+  display: block;
+  position: relative;
+}
+
+.audit-marker::before {
+  background: #697586;
+  border-radius: 999px;
+  content: "";
+  height: 8px;
+  left: 3px;
+  position: absolute;
+  top: 5px;
+  width: 8px;
+}
+
+.audit-marker::after {
+  background: #d7dee5;
+  bottom: -16px;
+  content: "";
+  left: 6px;
+  position: absolute;
+  top: 18px;
+  width: 1px;
+}
+
+.audit-event:last-child .audit-marker::after {
+  display: none;
+}
+
+.audit-kind-request .audit-marker::before {
+  background: #2563eb;
+}
+
+.audit-kind-response .audit-marker::before {
+  background: #16a34a;
+}
+
+.audit-kind-event-agent .audit-marker::before {
+  background: #c2410c;
+}
+
+.audit-kind-chat .audit-marker::before {
+  background: #0f766e;
+}
+
+.audit-event-body {
+  display: grid;
+  gap: 3px;
+  min-width: 0;
+}
+
+.audit-event-body strong {
+  font-size: 13px;
+}
+
+.audit-event-meta,
+.audit-event-summary {
+  color: #697586;
+  font-size: 12px;
+  line-height: 1.35;
+}
+
+.audit-detail {
+  background: #fbfcfd;
+  border: 1px solid #e4e9ee;
+  border-radius: 6px;
+  display: block;
+  margin-top: 10px;
+  min-height: 0;
+  overflow: auto;
+  padding: 12px;
+}
+
+.audit-detail-header {
+  border-bottom: 1px solid #e4e9ee;
+  display: block;
+  padding-bottom: 10px;
+}
+
+.audit-detail-title,
+.audit-detail-block-title {
+  color: #1f2933;
+  font-size: 13px;
+  line-height: 1.25;
+  margin: 0;
+}
+
+.audit-detail-meta {
+  align-items: center;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px 14px;
+  margin: 8px 0 0;
+}
+
+.audit-detail-meta div {
+  align-items: baseline;
+  display: inline-flex;
+  gap: 6px;
+  min-width: 0;
+}
+
+.audit-detail-meta dt {
+  color: #52616f;
+  font-size: 11px;
+  font-weight: 600;
+  line-height: 1.2;
+  margin: 0;
+  text-transform: uppercase;
+}
+
+.audit-detail-meta dd {
+  color: #1f2933;
+  font-size: 12px;
+  line-height: 1.25;
+  margin: 0;
+  overflow-wrap: anywhere;
+}
+
+.audit-detail-block {
+  border-top: 1px solid #e4e9ee;
+  display: block;
+  margin-top: 12px;
+  padding-top: 10px;
+}
+
+.audit-detail-header + .audit-detail-block {
+  border-top: 0;
+  margin-top: 10px;
+  padding-top: 0;
+}
+
+.audit-detail-block > * + * {
+  margin-top: 6px;
+}
+
+.detail-kv {
+  display: grid;
+  gap: 6px;
+  grid-template-columns: minmax(80px, 0.45fr) minmax(0, 1fr);
+}
+
+.detail-kv span {
+  color: #697586;
+  font-size: 12px;
+}
+
+.detail-kv strong {
+  font-size: 12px;
+  font-weight: 500;
+  overflow-wrap: anywhere;
+}
+
+.detail-list {
+  display: grid;
+  gap: 8px;
+}
+
+.detail-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+.detail-tags li {
+  margin: 0;
+}
+
+.detail-tags code {
+  background: #eef2f5;
+  border: 1px solid #d7dee5;
+  border-radius: 4px;
+  color: #1f2933;
+  display: inline-block;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+  font-size: 12px;
+  line-height: 1.25;
+  max-width: 100%;
+  overflow-wrap: anywhere;
+  padding: 2px 6px;
+}
+
+.detail-message {
+  background: #ffffff;
+  border: 1px solid #e4e9ee;
+  border-radius: 6px;
+  display: grid;
+  gap: 5px;
+  padding: 8px;
+}
+
+.detail-message span {
+  color: #52616f;
+  font-size: 11px;
+  text-transform: uppercase;
+}
+
+.detail-message p,
+.audit-detail pre,
+.raw-detail pre {
+  margin: 0;
+}
+
+.audit-detail pre,
+.raw-detail pre {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+  font-size: 12px;
+  line-height: 1.45;
+  overflow: auto;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.raw-detail {
+  border-top: 1px solid #e4e9ee;
+  padding-top: 10px;
+}
+
+.raw-detail summary {
+  color: #52616f;
+  cursor: pointer;
+  font-size: 12px;
+}
+
+.usage-summary {
+  display: grid;
+  gap: 8px;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  margin-top: 10px;
+}
+
+.usage-summary div {
+  background: #f7f9fb;
+  border: 1px solid #e4e9ee;
+  border-radius: 6px;
+  display: grid;
+  gap: 2px;
+  padding: 8px;
+}
+
+.usage-summary span,
+.usage-groups h3 {
+  color: #697586;
+  font-size: 12px;
+  margin: 0;
+}
+
+.usage-summary strong {
+  font-size: 14px;
+}
+
+.usage-groups {
+  display: grid;
+  gap: 12px;
+  margin-top: 14px;
+}
+
 .error {
   background: #fef2f2;
   border: 1px solid #fecaca;
@@ -344,6 +743,13 @@ textarea {
   width: 760px;
 }
 
+.audit-modal {
+  height: min(840px, calc(100vh - 32px));
+  max-height: calc(100vh - 32px);
+  max-width: calc(100vw - 32px);
+  width: min(1180px, calc(100vw - 32px));
+}
+
 .modal::backdrop {
   background: rgba(17, 24, 39, 0.36);
 }
@@ -355,12 +761,26 @@ textarea {
   padding: 16px;
 }
 
+.audit-modal .modal-shell {
+  grid-template-rows: auto minmax(0, 1fr);
+  height: 100%;
+  max-height: none;
+  overflow: hidden;
+}
+
 .modal-head {
   align-items: center;
   display: flex;
   justify-content: space-between;
 }
 
+.audit-modal-body {
+  display: grid;
+  gap: 16px;
+  grid-template-columns: minmax(320px, 0.75fr) minmax(0, 1fr);
+  min-height: 0;
+}
+
 .stats {
   border-top: 1px solid #e4e9ee;
   display: grid;
@@ -393,19 +813,40 @@ textarea {
 }
 
 @media (max-width: 860px) {
+  body {
+    height: auto;
+    overflow: auto;
+  }
+
   .layout {
     grid-template-columns: 1fr;
     height: auto;
+    overflow: visible;
   }
 
   .workspace {
     min-height: 70vh;
+    overflow: visible;
   }
 
   .stats,
   .composer,
   .pre-message,
+  .workspace-body,
+  .audit-modal-body,
+  .usage-summary,
   .button-row {
     grid-template-columns: 1fr;
   }
+
+  .replay-panel {
+    border-left: 0;
+    border-top: 1px solid #e4e9ee;
+    grid-template-rows: auto auto;
+    overflow: visible;
+  }
+
+  .audit-modal .modal-shell {
+    overflow: auto;
+  }
 }

+ 57 - 2
src/agent_lab/presentation/web.py

@@ -4,7 +4,7 @@ from collections.abc import Callable
 from pathlib import Path
 from typing import Any
 
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
 from fastapi.responses import FileResponse
 from fastapi.staticfiles import StaticFiles
 from pydantic import ValidationError
@@ -12,9 +12,11 @@ from pydantic import ValidationError
 from agent_lab.application.contracts import DebugRunRequest
 from agent_lab.application.queues import RuntimeQueues
 from agent_lab.application.runtime import DebugRuntime
+from agent_lab.application.session_store import SessionStore
 from agent_lab.application.tools import ToolRegistry, build_default_tool_registry
 from agent_lab.domain.messages import ChatMessage
 from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
+from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
 from agent_lab.settings import Settings
 
 
@@ -31,9 +33,11 @@ def create_app(
 ) -> FastAPI:
     resolved_settings = settings or Settings()
     tool_registry = build_default_tool_registry()
+    session_store = SQLiteSessionStore(resolved_settings.database_path)
     resolved_runtime_factory = runtime_factory or _runtime_factory(
         resolved_settings,
         tool_registry,
+        session_store,
     )
 
     app = FastAPI(title="Agent Lab")
@@ -51,6 +55,42 @@ def create_app(
     async def tools() -> list[dict[str, Any]]:
         return tool_registry.available_tools()
 
+    @app.post("/api/sessions")
+    async def create_session(payload: dict[str, Any] | None = None) -> dict[str, Any]:
+        payload = payload or {}
+        title = payload.get("title")
+        config = payload.get("config")
+        session_id = session_store.create_session(
+            title=title if isinstance(title, str) and title.strip() else "New session",
+            config=config if isinstance(config, dict) else {},
+        )
+        session = session_store.get_session(session_id)
+        assert session is not None
+        return session
+
+    @app.get("/api/sessions")
+    async def sessions() -> list[dict[str, Any]]:
+        return session_store.list_sessions()
+
+    @app.get("/api/sessions/{session_id}")
+    async def session_detail(session_id: str) -> dict[str, Any]:
+        return _require_session(session_store, session_id)
+
+    @app.get("/api/sessions/{session_id}/messages")
+    async def session_messages(session_id: str) -> list[dict[str, Any]]:
+        _require_session(session_store, session_id)
+        return session_store.list_messages(session_id)
+
+    @app.get("/api/sessions/{session_id}/audit")
+    async def session_audit(session_id: str) -> list[dict[str, Any]]:
+        _require_session(session_store, session_id)
+        return session_store.list_audit_logs(session_id)
+
+    @app.get("/api/sessions/{session_id}/usage")
+    async def session_usage(session_id: str) -> dict[str, Any]:
+        _require_session(session_store, session_id)
+        return session_store.usage_summary(session_id)
+
     @app.websocket("/ws/debug")
     async def debug(websocket: WebSocket) -> None:
         await websocket.accept()
@@ -142,6 +182,7 @@ def _parse_upstream_message(payload: Any) -> ChatMessage:
 def _runtime_factory(
     settings: Settings,
     tool_registry: ToolRegistry,
+    session_store: SessionStore,
 ) -> Callable[[], DebugRuntime]:
     def build_runtime() -> DebugRuntime:
         chat_client = OpenAICompatibleChatClient(
@@ -151,11 +192,25 @@ def _runtime_factory(
             request_timeout_seconds=settings.request_timeout_seconds,
             include_usage=settings.openai_include_usage,
         )
-        return DebugRuntime(chat_client, registry=tool_registry)
+        return DebugRuntime(
+            chat_client,
+            registry=tool_registry,
+            session_store=session_store,
+        )
 
     return build_runtime
 
 
+def _require_session(
+    session_store: SessionStore,
+    session_id: str,
+) -> dict[str, Any]:
+    session = session_store.get_session(session_id)
+    if session is None:
+        raise HTTPException(status_code=404, detail="session not found")
+    return session
+
+
 async def _close_runtime(runtime: Any) -> None:
     runtime_close = getattr(runtime, "aclose", None)
     if runtime_close is not None:

+ 1 - 0
src/agent_lab/settings.py

@@ -13,3 +13,4 @@ class Settings(BaseSettings):
     openai_default_model: str = "gpt-4.1-mini"
     openai_include_usage: bool = True
     request_timeout_seconds: float = 60.0
+    database_path: str = ".agent_lab.sqlite3"

+ 472 - 2
tests/test_debug_runtime.py

@@ -13,6 +13,7 @@ from agent_lab.application.runtime import DebugRuntime
 from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
+from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
 
 
 def _runtime_queues_class():
@@ -121,10 +122,37 @@ class FakeChatClient:
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
         if tools:
+            yield StreamItem.raw_response_chunk(
+                {
+                    "choices": [
+                        {
+                            "delta": {
+                                "tool_calls": [
+                                    {
+                                        "index": 0,
+                                        "function": {"name": tools[0]["function"]["name"]},
+                                    }
+                                ]
+                            },
+                            "finish_reason": None,
+                        }
+                    ]
+                }
+            )
             yield _event_tool_call_from_tools(tools, messages)
             return
         self.calls += 1
         if self.calls == 1:
+            yield StreamItem.raw_response_chunk(
+                {
+                    "choices": [
+                        {
+                            "delta": {"content": "<agent_events>handoff_note</agent_events>"},
+                            "finish_reason": None,
+                        }
+                    ]
+                }
+            )
             yield StreamItem.event(
                 ToolCallEvent(
                     id="call_1",
@@ -140,6 +168,16 @@ class FakeChatClient:
         yield StreamItem.message_delta("final answer")
 
 
+class IncrementingClock:
+    def __init__(self, current: float = 100.0, step: float = 0.01) -> None:
+        self.current = current
+        self.step = step
+
+    def __call__(self) -> float:
+        self.current += self.step
+        return self.current
+
+
 class StrictHistoryChatClient:
     def __init__(self) -> None:
         self.calls = 0
@@ -152,10 +190,37 @@ class StrictHistoryChatClient:
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
         if tools:
+            yield StreamItem.raw_response_chunk(
+                {
+                    "choices": [
+                        {
+                            "delta": {
+                                "tool_calls": [
+                                    {
+                                        "index": 0,
+                                        "function": {"name": tools[0]["function"]["name"]},
+                                    }
+                                ]
+                            },
+                            "finish_reason": None,
+                        }
+                    ]
+                }
+            )
             yield _event_tool_call_from_tools(tools, messages)
             return
         self.calls += 1
         if self.calls == 1:
+            yield StreamItem.raw_response_chunk(
+                {
+                    "choices": [
+                        {
+                            "delta": {"content": "<agent_events>handoff_note</agent_events>"},
+                            "finish_reason": None,
+                        }
+                    ]
+                }
+            )
             yield StreamItem.event(
                 ToolCallEvent(
                     id="call_1",
@@ -286,10 +351,41 @@ class EventRoundStatsChatClient:
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
         if tools:
+            yield StreamItem.raw_response_chunk(
+                {
+                    "choices": [
+                        {
+                            "delta": {
+                                "tool_calls": [
+                                    {
+                                        "index": 0,
+                                        "function": {
+                                            "name": tools[0]["function"]["name"],
+                                        },
+                                    }
+                                ]
+                            },
+                            "finish_reason": None,
+                        }
+                    ]
+                }
+            )
             yield _event_tool_call_from_tools(tools, messages)
             return
         self.calls += 1
         if self.calls == 1:
+            yield StreamItem.raw_response_chunk(
+                {
+                    "choices": [
+                        {
+                            "delta": {
+                                "content": "<agent_events>handoff_note</agent_events>",
+                            },
+                            "finish_reason": None,
+                        }
+                    ]
+                }
+            )
             yield StreamItem.event(
                 ToolCallEvent(
                     id="call_1",
@@ -303,12 +399,52 @@ class EventRoundStatsChatClient:
             )
             return
 
+        yield StreamItem.raw_response_chunk(
+            {
+                "choices": [
+                    {
+                        "delta": {"content": "final answer"},
+                        "finish_reason": None,
+                    }
+                ]
+            }
+        )
         yield StreamItem.message_delta("final answer")
         yield StreamItem.usage_item(
             TokenUsage(prompt_tokens=4, completion_tokens=6, total_tokens=10)
         )
 
 
+class ContextBoundaryChatClient:
+    def __init__(self) -> None:
+        self.calls = 0
+        self.event_agent_messages: list[list[ChatMessage]] = []
+
+    async def stream_chat(
+        self,
+        messages: list[ChatMessage],
+        tools: list[dict],
+        params: AgentParams,
+    ) -> AsyncIterator[StreamItem]:
+        if tools:
+            self.event_agent_messages.append(list(messages))
+            yield _event_tool_call_from_tools(tools, messages)
+            return
+        self.calls += 1
+        if self.calls == 1:
+            yield StreamItem.message_delta("I will check.")
+            yield StreamItem.event(
+                ToolCallEvent(
+                    id="call_1",
+                    name="handoff_note",
+                    arguments={},
+                    raw_arguments="{}",
+                )
+            )
+            return
+        yield StreamItem.message_delta("final answer")
+
+
 class TwoTurnSessionChatClient:
     def __init__(self) -> None:
         self.calls = 0
@@ -445,6 +581,8 @@ async def test_runtime_emits_audit_events_and_backend_logs(caplog):
         "chat_round_finished",
         "chat_round_started",
         "chat_agent_request",
+        "chat_message_stream_started",
+        "chat_message_stream_finished",
         "chat_agent_response",
         "chat_round_finished",
         "session_finished",
@@ -453,6 +591,87 @@ async def test_runtime_emits_audit_events_and_backend_logs(caplog):
     assert "event_agent_completed" in caplog.text
 
 
+@pytest.mark.asyncio
+async def test_runtime_audits_chat_message_stream_boundaries_in_output_order():
+    request = DebugRunRequest(
+        user_message="debug this",
+        system_prompts=[],
+        pre_messages=[],
+        chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
+        event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
+    )
+    runtime = DebugRuntime(RoundStatsChatClient())
+
+    outputs = [message async for message in runtime.run(request)]
+    ordered_labels = [
+        message["event"] if message["type"] == "audit" else message["type"]
+        for message in outputs
+    ]
+
+    assert ordered_labels.index("chat_message_stream_started") < ordered_labels.index(
+        "message_delta"
+    )
+    assert ordered_labels.index("message_delta") < ordered_labels.index(
+        "chat_message_stream_finished"
+    )
+    assert ordered_labels.index("chat_message_stream_finished") < ordered_labels.index(
+        "chat_agent_response"
+    )
+
+    stream_started = next(
+        message
+        for message in outputs
+        if message.get("event") == "chat_message_stream_started"
+    )
+    stream_finished = next(
+        message
+        for message in outputs
+        if message.get("event") == "chat_message_stream_finished"
+    )
+    assert stream_started["details"]["agent"] == "chat_agent"
+    assert stream_started["details"]["round_index"] == 1
+    assert stream_finished["details"]["delta_count"] == 1
+    assert stream_finished["details"]["content_length"] == len("hello")
+
+
+@pytest.mark.asyncio
+async def test_runtime_audit_events_include_turn_relative_elapsed_time():
+    request = DebugRunRequest(
+        user_message="debug this",
+        system_prompts=[],
+        pre_messages=[],
+        chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
+        event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
+    )
+    runtime = DebugRuntime(FakeChatClient(), clock=IncrementingClock())
+
+    queues = runtime.start_session(request)
+    outputs: list[dict[str, Any]] = []
+    while not any(message["type"] == "turn_completed" for message in outputs):
+        outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
+
+    await runtime.aclose()
+
+    turn_audits = [
+        message
+        for message in outputs
+        if message["type"] == "audit"
+    ]
+    elapsed_values = [
+        message["details"].get("turn_elapsed_ms") for message in turn_audits
+    ]
+
+    assert turn_audits
+    assert all(isinstance(value, int) for value in elapsed_values)
+    assert all(value >= 0 for value in elapsed_values)
+    assert elapsed_values == sorted(elapsed_values)
+    assert next(
+        message
+        for message in turn_audits
+        if message["event"] == "event_agent_request"
+    )["details"]["turn_elapsed_ms"] >= 0
+
+
 @pytest.mark.asyncio
 async def test_runtime_audit_includes_model_params_prompts_results_and_usage():
     request = DebugRunRequest(
@@ -492,6 +711,16 @@ async def test_runtime_audit_includes_model_params_prompts_results_and_usage():
         message for message in audits if message["event"] == "chat_agent_response"
     )
     assert chat_response["details"]["event_names"] == ["handoff_note"]
+    assert chat_response["details"]["raw_chunks"] == [
+        {
+            "choices": [
+                {
+                    "delta": {"content": "<agent_events>handoff_note</agent_events>"},
+                    "finish_reason": None,
+                }
+            ]
+        }
+    ]
     assert chat_response["details"]["usage"] == {
         "prompt_tokens": 3,
         "completion_tokens": 0,
@@ -514,6 +743,131 @@ async def test_runtime_audit_includes_model_params_prompts_results_and_usage():
     )
     assert event_response["details"]["replies"][0]["role"] == "tool"
     assert '"tool": "handoff_note"' in event_response["details"]["replies"][0]["content"]
+    assert event_response["details"]["raw_model_chunks"][0]["event_name"] == "handoff_note"
+    assert event_response["details"]["raw_model_chunks"][0]["chunks"][0]["choices"][0]["delta"] == {
+        "tool_calls": [
+            {
+                "index": 0,
+                "function": {"name": "handoff_note"},
+            }
+        ]
+    }
+
+
+@pytest.mark.asyncio
+async def test_runtime_round_started_separates_available_events_from_round_budget():
+    request = DebugRunRequest(
+        user_message="debug this",
+        system_prompts=[],
+        pre_messages=[],
+        chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
+        event_agent=EventAgentParams(
+            enabled_tools=["handoff_note"],
+            max_event_loops=1,
+        ),
+    )
+    runtime = DebugRuntime(EventRoundStatsChatClient())
+
+    outputs = [message async for message in runtime.run(request)]
+    round_starts = [
+        message
+        for message in outputs
+        if message.get("event") == "chat_round_started"
+    ]
+
+    assert round_starts[0]["details"]["events_enabled"] == ["handoff_note"]
+    assert round_starts[0]["details"]["configured_events"] == ["handoff_note"]
+    assert round_starts[0]["details"]["event_generation_enabled"] is True
+    assert round_starts[0]["details"]["event_prompt_events"] == ["handoff_note"]
+    assert round_starts[1]["details"]["events_enabled"] == []
+    assert round_starts[1]["details"]["configured_events"] == ["handoff_note"]
+    assert round_starts[1]["details"]["event_generation_enabled"] is False
+    assert round_starts[1]["details"]["event_prompt_events"] == []
+
+
+@pytest.mark.asyncio
+async def test_runtime_event_agent_history_excludes_chat_agent_system_context():
+    request = DebugRunRequest(
+        user_message="debug this",
+        system_prompts=["ChatAgent root prompt."],
+        pre_messages=[
+            ChatMessage(role="system", content="ChatAgent pre system."),
+            ChatMessage(role="user", content="earlier user"),
+            ChatMessage(role="assistant", content="earlier assistant"),
+        ],
+        chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
+        event_agent=EventAgentParams(
+            model="event-model",
+            enabled_tools=["handoff_note"],
+            max_event_loops=1,
+        ),
+    )
+    client = ContextBoundaryChatClient()
+    runtime = DebugRuntime(client)
+
+    outputs = [message async for message in runtime.run(request)]
+    event_request = next(
+        message for message in outputs if message.get("event") == "event_agent_request"
+    )
+
+    assert [
+        (message["role"], message["content"])
+        for message in event_request["details"]["history"]
+    ] == [
+        ("user", "earlier user"),
+        ("assistant", "earlier assistant"),
+        ("user", "debug this"),
+        ("assistant", "I will check."),
+    ]
+    assert not any(
+        message["role"] == "system"
+        for message in event_request["details"]["history"]
+    )
+    assert client.event_agent_messages
+    assert [
+        (message.role, message.content)
+        for message in client.event_agent_messages[0]
+        if message.content in {"ChatAgent root prompt.", "ChatAgent pre system."}
+    ] == []
+
+
+@pytest.mark.asyncio
+async def test_runtime_session_event_agent_history_excludes_internal_replies():
+    request = DebugRunRequest(
+        user_message="debug this",
+        system_prompts=["ChatAgent root prompt."],
+        pre_messages=[ChatMessage(role="assistant", content="prior answer")],
+        chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
+        event_agent=EventAgentParams(
+            model="event-model",
+            enabled_tools=["handoff_note"],
+            max_event_loops=1,
+        ),
+    )
+    runtime = DebugRuntime(ContextBoundaryChatClient())
+
+    queues = runtime.start_session(request)
+    outputs: list[dict[str, Any]] = []
+    while not any(message["type"] == "turn_completed" for message in outputs):
+        outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
+
+    await runtime.aclose()
+
+    event_request = next(
+        message for message in outputs if message.get("event") == "event_agent_request"
+    )
+    assert [
+        (message["role"], message["content"], message["name"])
+        for message in event_request["details"]["history"]
+    ] == [
+        ("assistant", "prior answer", None),
+        ("user", "debug this", None),
+        ("assistant", "I will check.", None),
+    ]
+    assert not any(
+        message["name"] == "event_agent"
+        for message in event_request["details"]["history"]
+    )
 
 
 @pytest.mark.asyncio
@@ -950,7 +1304,22 @@ async def test_runtime_emits_round_stats_with_clock_and_usage_after_model_turn()
         chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
         event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
     )
-    ticks = iter([1.0, 1.123, 1.456])
+    ticks = iter(
+        [
+            0.9,
+            0.91,
+            1.0,
+            1.01,
+            1.02,
+            1.123,
+            1.2,
+            1.25,
+            1.3,
+            1.456,
+            1.7,
+            1.8,
+        ]
+    )
     runtime = DebugRuntime(RoundStatsChatClient(), clock=lambda: next(ticks))
 
     outputs = [message async for message in runtime.run(request)]
@@ -985,7 +1354,34 @@ async def test_runtime_emits_round_stats_for_each_chat_call_in_event_handoff():
         chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
         event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
     )
-    ticks = iter([2.0, 2.25, 3.0, 3.05, 3.2])
+    ticks = iter(
+        [
+            1.9,
+            1.91,
+            2.0,
+            2.01,
+            2.02,
+            2.03,
+            2.04,
+            2.05,
+            2.06,
+            2.07,
+            2.25,
+            2.26,
+            3.0,
+            3.01,
+            3.02,
+            3.05,
+            3.1,
+            3.15,
+            3.18,
+            3.2,
+            3.23,
+            3.24,
+            3.25,
+            3.26,
+        ]
+    )
     client = EventRoundStatsChatClient()
     runtime = DebugRuntime(client, clock=lambda: next(ticks))
 
@@ -1048,3 +1444,77 @@ async def test_runtime_session_resets_event_budget_for_each_user_turn():
     assert client.calls == 4
     assert "Available events:" in client.messages_by_call[0][0].content
     assert "Available events:" in client.messages_by_call[2][0].content
+
+
+@pytest.mark.asyncio
+async def test_runtime_persists_session_turn_messages_audit_and_usage(tmp_path):
+    store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
+    request = DebugRunRequest(
+        session_id="session-1",
+        user_message="persist this",
+        system_prompts=["You are a debugger."],
+        pre_messages=[],
+        chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
+        event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
+    )
+    runtime = DebugRuntime(RoundStatsChatClient(), session_store=store)
+
+    queues = runtime.start_session(request)
+    outputs: list[dict[str, Any]] = []
+    while not any(message["type"] == "turn_completed" for message in outputs):
+        outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
+    await runtime.aclose()
+
+    assert _without_audit(outputs)[0] == {
+        "type": "session_started",
+        "session_id": "session-1",
+    }
+    assert store.get_session("session-1")["config"]["chat_agent"]["model"] == "chat-model"
+    assert [
+        (message["turn_index"], message["role"], message["content"])
+        for message in store.list_messages("session-1")
+    ] == [
+        (1, "user", "persist this"),
+        (1, "assistant", "hello"),
+    ]
+    audit_events = [
+        audit["event"]
+        for audit in store.list_audit_logs("session-1")
+    ]
+    assert "session_started" in audit_events
+    assert "chat_agent_request" in audit_events
+    assert "turn_completed" in audit_events
+    usage = store.usage_summary("session-1")
+    assert usage["calls"][0]["total_tokens"] == 30
+    assert usage["turns"][0]["turn_index"] == 1
+    assert usage["session"]["total_tokens"] == 30
+
+
+@pytest.mark.asyncio
+async def test_runtime_continues_persisted_turn_indexes_for_existing_session(tmp_path):
+    store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
+    session_id = store.create_session(title="existing", config={})
+    store.start_turn(session_id, turn_index=1, user_message="old")
+    request = DebugRunRequest(
+        session_id=session_id,
+        user_message="new",
+        system_prompts=[],
+        pre_messages=[],
+        chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
+        event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
+    )
+    runtime = DebugRuntime(RoundStatsChatClient(), session_store=store)
+
+    queues = runtime.start_session(request)
+    outputs: list[dict[str, Any]] = []
+    while not any(message["type"] == "turn_completed" for message in outputs):
+        outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
+    await runtime.aclose()
+
+    assert [
+        (message["turn_index"], message["content"])
+        for message in store.list_messages(session_id)
+    ] == [
+        (2, "new"),
+        (2, "hello"),
+    ]

+ 40 - 0
tests/test_event_agent.py

@@ -29,6 +29,23 @@ class ToolCallingChatClient:
             }
         )
         tool_name = tools[0]["function"]["name"]
+        yield StreamItem.raw_response_chunk(
+            {
+                "choices": [
+                    {
+                        "delta": {
+                            "tool_calls": [
+                                {
+                                    "index": 0,
+                                    "function": {"name": tool_name},
+                                }
+                            ]
+                        },
+                        "finish_reason": None,
+                    }
+                ]
+            }
+        )
         yield StreamItem.event(
             ToolCallEvent(
                 id="llm_call_1",
@@ -104,6 +121,29 @@ async def test_event_agent_resolves_tool_arguments_with_llm_tool_call():
         }
     ]
     assert chat_client.calls[0]["params"].model == "event-model"
+    assert agent.raw_model_chunks([event]) == [
+        {
+            "event_id": "call_1",
+            "event_name": "handoff_note",
+            "chunks": [
+                {
+                    "choices": [
+                        {
+                            "delta": {
+                                "tool_calls": [
+                                    {
+                                        "index": 0,
+                                        "function": {"name": "handoff_note"},
+                                    }
+                                ]
+                            },
+                            "finish_reason": None,
+                        }
+                    ]
+                }
+            ],
+        }
+    ]
 
 
 @pytest.mark.asyncio

+ 97 - 0
tests/test_sqlite_store.py

@@ -0,0 +1,97 @@
+from agent_lab.domain.messages import ChatMessage, TokenUsage
+from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
+
+
+def test_sqlite_store_persists_session_messages_audit_and_usage(tmp_path):
+    store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
+
+    session_id = store.create_session(
+        title="Debug first turn",
+        config={"chat_agent": {"model": "chat-model"}},
+    )
+    store.start_turn(session_id, turn_index=1, user_message="debug this")
+    store.append_message(
+        session_id,
+        turn_index=1,
+        message=ChatMessage(role="user", content="debug this"),
+    )
+    store.append_message(
+        session_id,
+        turn_index=1,
+        message=ChatMessage(role="assistant", content="answer"),
+    )
+    store.append_audit(
+        session_id,
+        event="chat_agent_request",
+        details={"model": "chat-model"},
+        turn_index=1,
+        round_index=1,
+    )
+    store.append_usage(
+        session_id,
+        turn_index=1,
+        round_index=1,
+        usage=TokenUsage(
+            prompt_tokens=3,
+            completion_tokens=7,
+            total_tokens=10,
+            cached_tokens=2,
+        ),
+        ttft_ms=120,
+        elapsed_ms=450,
+    )
+    store.complete_turn(session_id, turn_index=1)
+
+    sessions = store.list_sessions()
+    assert sessions == [
+        {
+            "id": session_id,
+            "title": "Debug first turn",
+            "created_at": sessions[0]["created_at"],
+            "updated_at": sessions[0]["updated_at"],
+            "turn_count": 1,
+            "total_tokens": 10,
+        }
+    ]
+    assert store.get_session(session_id)["config"]["chat_agent"]["model"] == "chat-model"
+    assert [message["content"] for message in store.list_messages(session_id)] == [
+        "debug this",
+        "answer",
+    ]
+    assert store.list_audit_logs(session_id)[0]["event"] == "chat_agent_request"
+    usage = store.usage_summary(session_id)
+    assert usage["calls"][0]["total_tokens"] == 10
+    assert usage["turns"] == [
+        {
+            "turn_index": 1,
+            "prompt_tokens": 3,
+            "completion_tokens": 7,
+            "total_tokens": 10,
+            "cached_tokens": 2,
+            "elapsed_ms": 450,
+        }
+    ]
+    assert usage["session"]["total_tokens"] == 10
+
+
+def test_sqlite_store_session_list_totals_are_not_multiplied_by_turns(tmp_path):
+    store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
+    session_id = store.create_session(title="two turns", config={})
+
+    for turn_index, tokens in [(1, 2), (2, 4)]:
+        store.start_turn(
+            session_id,
+            turn_index=turn_index,
+            user_message=f"turn {turn_index}",
+        )
+        store.append_usage(
+            session_id,
+            turn_index=turn_index,
+            round_index=1,
+            usage=TokenUsage(total_tokens=tokens),
+            ttft_ms=None,
+            elapsed_ms=10,
+        )
+
+    assert store.list_sessions()[0]["turn_count"] == 2
+    assert store.list_sessions()[0]["total_tokens"] == 6

+ 252 - 4
tests/test_websocket_api.py

@@ -13,9 +13,17 @@ from agent_lab.application.contracts import AgentParams, DebugRunRequest, EventA
 from agent_lab.application.queues import RuntimeQueues
 from agent_lab.application.runtime import DebugRuntime
 from agent_lab.domain.events import ToolCallEvent
-from agent_lab.domain.messages import ChatMessage, StreamItem
+from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
 from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
+from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
 from agent_lab.presentation.web import create_app
+from agent_lab.settings import Settings
+
+
+def _css_rule(css: str, selector: str) -> str:
+    start = css.index(f"{selector} {{")
+    end = css.index("}", start)
+    return css[start:end]
 
 
 class FakeRuntime:
@@ -181,6 +189,108 @@ def test_api_tools_returns_handoff_note_metadata():
     assert tools[0]["parameters"]["required"] == ["message"]
 
 
+def test_session_api_creates_lists_and_returns_empty_replay_data(tmp_path):
+    settings = Settings(database_path=str(tmp_path / "agent_lab.sqlite3"))
+    app = create_app(settings=settings, runtime_factory=FakeRuntime)
+    client = TestClient(app)
+
+    created = client.post(
+        "/api/sessions",
+        json={
+            "title": "Debug session",
+            "config": {"chat_agent": {"model": "chat-model"}},
+        },
+    )
+
+    assert created.status_code == 200
+    session_id = created.json()["id"]
+    assert created.json()["title"] == "Debug session"
+
+    sessions = client.get("/api/sessions")
+    assert sessions.status_code == 200
+    assert sessions.json()[0]["id"] == session_id
+    assert sessions.json()[0]["turn_count"] == 0
+
+    detail = client.get(f"/api/sessions/{session_id}")
+    assert detail.status_code == 200
+    assert detail.json()["config"]["chat_agent"]["model"] == "chat-model"
+
+    assert client.get(f"/api/sessions/{session_id}/messages").json() == []
+    assert client.get(f"/api/sessions/{session_id}/audit").json() == []
+    usage = client.get(f"/api/sessions/{session_id}/usage").json()
+    assert usage == {
+        "calls": [],
+        "turns": [],
+        "session": {
+            "prompt_tokens": 0,
+            "completion_tokens": 0,
+            "total_tokens": 0,
+            "cached_tokens": 0,
+            "elapsed_ms": 0,
+        },
+    }
+
+
+def test_session_api_returns_persisted_replay_data(tmp_path):
+    database_path = tmp_path / "agent_lab.sqlite3"
+    settings = Settings(database_path=str(database_path))
+    app = create_app(settings=settings, runtime_factory=FakeRuntime)
+    client = TestClient(app)
+
+    session_id = client.post("/api/sessions", json={"title": "Replay"}).json()["id"]
+    store = SQLiteSessionStore(database_path)
+    store.start_turn(session_id, turn_index=1, user_message="debug this")
+    store.append_message(
+        session_id,
+        turn_index=1,
+        message=ChatMessage(role="user", content="debug this"),
+    )
+    store.append_message(
+        session_id,
+        turn_index=1,
+        message=ChatMessage(role="assistant", content="answer"),
+    )
+    store.append_audit(
+        session_id,
+        event="chat_agent_request",
+        details={"model": "chat-model"},
+        turn_index=1,
+        round_index=1,
+    )
+    store.append_usage(
+        session_id,
+        turn_index=1,
+        round_index=1,
+        usage=TokenUsage(prompt_tokens=1, completion_tokens=2, total_tokens=3),
+        ttft_ms=10,
+        elapsed_ms=20,
+    )
+
+    assert [message["content"] for message in client.get(
+        f"/api/sessions/{session_id}/messages"
+    ).json()] == ["debug this", "answer"]
+    assert client.get(f"/api/sessions/{session_id}/audit").json()[0]["details"] == {
+        "model": "chat-model"
+    }
+    usage = client.get(f"/api/sessions/{session_id}/usage").json()
+    assert usage["calls"][0]["total_tokens"] == 3
+    assert usage["turns"][0]["total_tokens"] == 3
+    assert usage["session"]["total_tokens"] == 3
+
+
+def test_default_websocket_runtime_factory_accepts_session_store(tmp_path):
+    settings = Settings(database_path=str(tmp_path / "agent_lab.sqlite3"))
+    app = create_app(settings=settings)
+    client = TestClient(app)
+
+    with client.websocket_connect("/ws/debug") as websocket:
+        websocket.send_json({"chat_agent": {"model": "fake-model"}})
+        message = websocket.receive_json()
+
+    assert message["type"] == "error"
+    assert "user_message" in message["message"]
+
+
 def test_websocket_debug_streams_runtime_messages():
     runtime = FakeRuntime()
     app = create_app(runtime_factory=lambda: runtime)
@@ -428,7 +538,10 @@ async def test_openai_chat_client_streams_sse_chunks_through_parser():
         ]
 
     assert requests[0].url.path == "/v1/chat/completions"
-    assert [item.content for item in items] == ["hi"]
+    assert [item.content for item in items if item.kind == "message_delta"] == ["hi"]
+    assert [item.raw_chunk for item in items if item.kind == "raw_chunk"] == [
+        {"choices": [{"delta": {"content": "hi"}, "finish_reason": None}]}
+    ]
 
 
 @pytest.mark.asyncio
@@ -546,6 +659,36 @@ def test_static_workspace_snapshot_controls_are_available_outside_agent_config()
     assert 'id="pre-messages"' in chat_dialog_html
 
 
+def test_static_session_ui_controls_and_replay_panels_are_available():
+    html = Path("src/agent_lab/presentation/static/index.html").read_text()
+    chat_dialog_start = html.index('<dialog id="chat-config-dialog"')
+    event_dialog_start = html.index('<dialog id="event-config-dialog"')
+    chat_dialog_html = html[chat_dialog_start:event_dialog_start]
+    event_dialog_html = html[event_dialog_start:]
+    sidebar_html = html[:chat_dialog_start]
+
+    for control_id in [
+        "session-title",
+        "session-list",
+        "new-session",
+        "refresh-sessions",
+        "load-session",
+        "session-status",
+    ]:
+        assert f'id="{control_id}"' in sidebar_html
+        assert f'id="{control_id}"' not in chat_dialog_html
+        assert f'id="{control_id}"' not in event_dialog_html
+
+    for replay_id in [
+        "audit-replay",
+        "audit-count",
+        "session-usage-summary",
+        "session-usage-turns",
+        "session-usage-calls",
+    ]:
+        assert f'id="{replay_id}"' in html
+
+
 def test_static_chat_agent_prompt_is_a_single_sortable_list_with_event_instructions():
     html = Path("src/agent_lab/presentation/static/index.html").read_text()
     js = Path("src/agent_lab/presentation/static/app.js").read_text()
@@ -650,8 +793,72 @@ def test_static_app_handles_audit_messages():
     css = Path("src/agent_lab/presentation/static/styles.css").read_text()
 
     assert 'message.type === "audit"' in js
-    assert 'appendLog("audit"' in js
-    assert ".audit" in css
+    assert "appendAuditEntry(message)" in js
+    assert 'appendLog("audit"' not in js
+    assert "function renderAuditDetail(" in js
+    assert "chat_agent_request" in js
+    assert "event_agent_response" in js
+    assert "chat_message_stream_started" in js
+    assert "chat_message_stream_finished" in js
+    assert ".audit-event" in css
+    assert ".audit-detail" in css
+
+
+def test_static_audit_replay_uses_event_stream_and_detail_panel():
+    html = Path("src/agent_lab/presentation/static/index.html").read_text()
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    css = Path("src/agent_lab/presentation/static/styles.css").read_text()
+
+    assert '<link rel="stylesheet" href="/static/styles.css?v=' in html
+    assert "session-switch-idle-socket" in html
+    assert 'id="audit-dialog"' in html
+    assert 'id="open-audit-dialog"' in html
+    assert 'id="close-audit-dialog"' in html
+    assert 'id="audit-summary"' in html
+    assert 'id="audit-replay" class="audit-stream"' in html
+    assert 'id="audit-detail"' in html
+    assert "auditDialog.showModal()" in js
+    assert "auditDialog.close()" in js
+    assert "auditEntryKey(entry)" in js
+    assert "selectAuditEntry(key)" in js
+    assert 'meta.className = "audit-detail-meta"' in js
+    assert 'appendMetaField(meta, "Since User Message"' in js
+    assert "auditRelativeTime(entry)" in js
+    assert 'const list = document.createElement("ul")' in js
+    assert "appendKeyValueSection(auditDetail, \"Model Parameters\"" in js
+    assert "appendRepliesSection(auditDetail, details.replies)" in js
+    assert "appendRawChunksSection(auditDetail, \"Raw LLM Chunks\"" in js
+    assert "appendRawModelChunksSection(auditDetail, details.raw_model_chunks)" in js
+    assert "Enabled This Round" in js
+    assert "Configured Events" in js
+    assert "details.configured_events" in js
+    assert "enabled this round" in js
+    assert ".audit-modal" in css
+    assert ".audit-modal-body" in css
+    assert ".audit-modal-body section + section" in css
+    assert ".audit-detail-title" in css
+    assert ".detail-tags code" in css
+    assert ".audit-detail-meta span" not in css
+    assert ".detail-tags span" not in css
+    assert "display: block" in _css_rule(css, ".audit-detail")
+    assert "display: grid" not in _css_rule(css, ".audit-detail")
+    assert "display: block" in _css_rule(css, ".audit-detail-header")
+    assert "display: grid" not in _css_rule(css, ".audit-detail-header")
+    assert "display: inline-flex" in _css_rule(css, ".audit-detail-meta div")
+    assert ".audit-marker::before" in css
+    assert ".audit-event.is-selected" in css
+
+
+def test_static_layout_keeps_controls_and_composer_fixed_with_scrollable_panes():
+    css = Path("src/agent_lab/presentation/static/styles.css").read_text()
+
+    assert "height: calc(100vh - 56px)" in css
+    assert ".layout" in css and "overflow: hidden" in css
+    assert ".controls" in css and "overflow: auto" in css
+    assert ".workspace {\n  display: grid;\n  grid-template-rows: minmax(0, 1fr) auto auto;" in css
+    assert ".workspace-body" in css and "overflow: hidden" in css
+    assert ".messages" in css and "overflow: auto" in css
+    assert ".audit-stream" in css and "overflow: auto" in css
 
 
 def test_static_app_shows_wait_state_and_immediate_user_echo():
@@ -676,10 +883,51 @@ def test_static_app_reuses_websocket_for_session_turns():
 
     assert "function isSocketOpen()" in js
     assert "socket.send(JSON.stringify({ type: \"user_message\"" in js
+    assert "session_id: currentSessionId || undefined" in js
+    assert "message.session_id" in js
     assert 'message.type === "turn_completed"' in js
     assert 'message.type === "done"' in js
 
 
+def test_static_app_loads_session_replay_and_usage():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+
+    assert "function loadSessions(" in js
+    assert "function createSession(" in js
+    assert "function setSessionStatus(" in js
+    assert '"Refreshing sessions"' in js
+    assert '"Creating session"' in js
+    assert '"Session created"' in js
+    assert 'sessionStatus.textContent = message' in js
+    assert "function loadSessionReplay(" in js
+    assert 'fetchJson("/api/sessions")' in js
+    assert 'method: "POST"' in js
+    assert '`/api/sessions/${sessionId}/messages`' in js
+    assert '`/api/sessions/${sessionId}/audit`' in js
+    assert '`/api/sessions/${sessionId}/usage`' in js
+    assert "function renderAuditReplay(" in js
+    assert "function renderSessionUsage(" in js
+    assert "sessionUsageSummary" in js
+    assert "Finish current turn before switching sessions" in js
+
+
+def test_static_session_switch_guard_uses_active_turn_not_open_socket():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    create_session = js[js.index("async function createSession()") : js.index("async function loadSelectedSession()")]
+    load_session = js[js.index("async function loadSelectedSession()") : js.index("async function loadSessionReplay(")]
+
+    assert "function hasActiveTurn()" in js
+    assert "turnActive = isRunning" in js
+    assert "if (hasActiveTurn())" in create_session
+    assert "if (hasActiveTurn())" in load_session
+    assert "if (isSocketOpen())" not in create_session
+    assert "if (isSocketOpen())" not in load_session
+    assert "closeSocket();" in create_session
+    assert "closeSocket();" in load_session
+    assert "const activeSocket = socket" in js
+    assert "if (socket !== activeSocket)" in js
+
+
 def test_static_workspace_snapshot_uses_stable_storage_hooks():
     js = Path("src/agent_lab/presentation/static/app.js").read_text()