Prechádzať zdrojové kódy

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 týždňov pred
rodič
commit
3c8ac6aec7
31 zmenil súbory, kde vykonal 3382 pridanie a 47 odobranie
  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
 .env
 .venv/
 .venv/
 .superpowers/
 .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). |
 | 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). |
 | 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). |
 | 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):
 class DebugRunRequest(BaseModel):
     model_config = ConfigDict(extra="forbid")
     model_config = ConfigDict(extra="forbid")
 
 
+    session_id: str | None = None
     user_message: str
     user_message: str
     system_prompts: list[str] = Field(default_factory=list)
     system_prompts: list[str] = Field(default_factory=list)
     pre_messages: list[ChatMessage] = 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]
     history: list[ChatMessage]
     system_prompt: str = ""
     system_prompt: str = ""
     extra_body: dict[str, Any] | None = None
     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:
 class EventAgent:
@@ -44,6 +48,7 @@ class EventAgent:
         self.registry = registry or build_default_tool_registry()
         self.registry = registry or build_default_tool_registry()
         self.chat_client = chat_client
         self.chat_client = chat_client
         self.params = params or AgentParams()
         self.params = params or AgentParams()
+        self._raw_chunks_by_event: dict[str, list[dict[str, Any]]] = {}
 
 
     async def handle(
     async def handle(
         self,
         self,
@@ -52,6 +57,7 @@ class EventAgent:
         system_prompt: str = "",
         system_prompt: str = "",
         extra_body: dict[str, Any] | None = None,
         extra_body: dict[str, Any] | None = None,
     ) -> ChatMessage:
     ) -> ChatMessage:
+        self._raw_chunks_by_event[event.id] = []
         if event.name not in self.enabled_tools:
         if event.name not in self.enabled_tools:
             return self._tool_reply(
             return self._tool_reply(
                 event,
                 event,
@@ -103,6 +109,8 @@ class EventAgent:
         system_prompt: str = "",
         system_prompt: str = "",
         extra_body: dict[str, Any] | None = None,
         extra_body: dict[str, Any] | None = None,
     ) -> list[ChatMessage]:
     ) -> list[ChatMessage]:
+        for event in events:
+            self._raw_chunks_by_event[event.id] = []
         return await asyncio.gather(
         return await asyncio.gather(
             *[
             *[
                 self.handle(
                 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(
     async def _resolve_event_with_llm(
         self,
         self,
         event: ToolCallEvent,
         event: ToolCallEvent,
@@ -135,12 +156,18 @@ class EventAgent:
                 ),
                 ),
             }
             }
         )
         )
+        raw_chunks: list[dict[str, Any]] = []
         async for item in self.chat_client.stream_chat(
         async for item in self.chat_client.stream_chat(
             messages=messages,
             messages=messages,
             tools=[tool],
             tools=[tool],
             params=params,
             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:
             if item.kind == "event" and item.event is not None:
+                self._raw_chunks_by_event[event.id] = raw_chunks
                 return event.model_copy(
                 return event.model_copy(
                     update={
                     update={
                         "arguments": item.event.arguments,
                         "arguments": item.event.arguments,
@@ -148,6 +175,7 @@ class EventAgent:
                     }
                     }
                 )
                 )
 
 
+        self._raw_chunks_by_event[event.id] = raw_chunks
         return None
         return None
 
 
     def _build_argument_messages(
     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.contracts import AgentParams, DebugRunRequest
 from agent_lab.application.event_agent import EventAgent, EventAgentRequest
 from agent_lab.application.event_agent import EventAgent, EventAgentRequest
 from agent_lab.application.queues import RuntimeQueues
 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.application.tools import ToolRegistry, build_default_tool_registry
 from agent_lab.domain.events import EVENT_BLOCK_START, ToolCallEvent
 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__)
 logger = logging.getLogger(__name__)
@@ -31,11 +32,13 @@ class DebugRuntime:
         chat_client: ChatClient,
         chat_client: ChatClient,
         queues: RuntimeQueues | None = None,
         queues: RuntimeQueues | None = None,
         registry: ToolRegistry | None = None,
         registry: ToolRegistry | None = None,
+        session_store: SessionStore | None = None,
         clock: Callable[[], float] = time.perf_counter,
         clock: Callable[[], float] = time.perf_counter,
     ) -> None:
     ) -> None:
         self.chat_client = chat_client
         self.chat_client = chat_client
         self.queues = queues
         self.queues = queues
         self.registry = registry or build_default_tool_registry()
         self.registry = registry or build_default_tool_registry()
+        self.session_store = session_store
         self.clock = clock
         self.clock = clock
         self._tasks: list[asyncio.Task[None]] = []
         self._tasks: list[asyncio.Task[None]] = []
 
 
@@ -125,10 +128,11 @@ class DebugRuntime:
         queues: RuntimeQueues,
         queues: RuntimeQueues,
     ) -> None:
     ) -> None:
         messages = self._build_initial_messages(request)
         messages = self._build_initial_messages(request)
+        turn_started_at = self.clock()
         await queues.input.put(ChatMessage(role="user", content=request.user_message))
         await queues.input.put(ChatMessage(role="user", content=request.user_message))
 
 
         await queues.output.put({"type": "session_started"})
         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
         event_loops = 0
         round_index = 0
         round_index = 0
@@ -145,21 +149,30 @@ class DebugRuntime:
             assistant_content: list[str] = []
             assistant_content: list[str] = []
             events: list[ToolCallEvent] = []
             events: list[ToolCallEvent] = []
             tool_replies: list[ChatMessage] = []
             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
                 request.event_agent.enabled_tools
                 if event_loops < request.event_agent.max_event_loops
                 if event_loops < request.event_agent.max_event_loops
                 else []
                 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(
             await self._audit(
                 queues,
                 queues,
                 "chat_round_started",
                 "chat_round_started",
+                turn_started_at=turn_started_at,
                 round_index=round_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(
             await self._audit(
                 queues,
                 queues,
                 "chat_agent_request",
                 "chat_agent_request",
+                turn_started_at=turn_started_at,
                 agent="chat_agent",
                 agent="chat_agent",
                 round_index=round_index,
                 round_index=round_index,
                 params=self._params_snapshot(request.chat_agent),
                 params=self._params_snapshot(request.chat_agent),
@@ -172,9 +185,24 @@ class DebugRuntime:
                 tools=[],
                 tools=[],
                 params=request.chat_agent,
                 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 item.kind == "message_delta":
                     if ttft_ms is None:
                     if ttft_ms is None:
                         ttft_ms = self._elapsed_ms(started_at)
                         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 "")
                     assistant_content.append(item.content or "")
                     await queues.output.put(
                     await queues.output.put(
                         {"type": "message_delta", "content": item.content}
                         {"type": "message_delta", "content": item.content}
@@ -201,14 +229,26 @@ class DebugRuntime:
                     await self._audit(
                     await self._audit(
                         queues,
                         queues,
                         "chat_event_detected",
                         "chat_event_detected",
+                        turn_started_at=turn_started_at,
                         round_index=round_index,
                         round_index=round_index,
                         event_id=event.id,
                         event_id=event.id,
                         event_name=event.name,
                         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(
             await self._audit(
                 queues,
                 queues,
                 "chat_agent_response",
                 "chat_agent_response",
+                turn_started_at=turn_started_at,
                 agent="chat_agent",
                 agent="chat_agent",
                 round_index=round_index,
                 round_index=round_index,
                 content="".join(assistant_content),
                 content="".join(assistant_content),
@@ -219,6 +259,7 @@ class DebugRuntime:
                     "total_tokens": total_tokens,
                     "total_tokens": total_tokens,
                     "cached_tokens": cached_tokens,
                     "cached_tokens": cached_tokens,
                 },
                 },
+                raw_chunks=raw_chunks,
             )
             )
 
 
             if assistant_content or events:
             if assistant_content or events:
@@ -232,9 +273,10 @@ class DebugRuntime:
                 await queues.events.put(
                 await queues.events.put(
                     EventAgentRequest(
                     EventAgentRequest(
                         events=events,
                         events=events,
-                        history=list(messages),
+                        history=self._event_agent_history(messages),
                         system_prompt=request.event_agent.system_prompt,
                         system_prompt=request.event_agent.system_prompt,
                         extra_body=request.event_agent.extra_body,
                         extra_body=request.event_agent.extra_body,
+                        turn_started_at=turn_started_at,
                     )
                     )
                 )
                 )
                 tool_replies = await self._wait_for_tool_replies(
                 tool_replies = await self._wait_for_tool_replies(
@@ -248,6 +290,7 @@ class DebugRuntime:
                 await self._audit(
                 await self._audit(
                     queues,
                     queues,
                     "event_agent_completed",
                     "event_agent_completed",
+                    turn_started_at=turn_started_at,
                     round_index=round_index,
                     round_index=round_index,
                     event_names=[event.name for event in events],
                     event_names=[event.name for event in events],
                     result_count=len(tool_replies),
                     result_count=len(tool_replies),
@@ -271,6 +314,7 @@ class DebugRuntime:
             await self._audit(
             await self._audit(
                 queues,
                 queues,
                 "chat_round_finished",
                 "chat_round_finished",
+                turn_started_at=turn_started_at,
                 round_index=round_index,
                 round_index=round_index,
                 had_event=saw_event,
                 had_event=saw_event,
                 elapsed_ms=elapsed_ms,
                 elapsed_ms=elapsed_ms,
@@ -281,7 +325,12 @@ class DebugRuntime:
 
 
             event_loops += 1
             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"})
         await queues.output.put({"type": "done"})
 
 
     async def _run_chat_session(
     async def _run_chat_session(
@@ -289,24 +338,39 @@ class DebugRuntime:
         request: DebugRunRequest,
         request: DebugRunRequest,
         queues: RuntimeQueues,
         queues: RuntimeQueues,
     ) -> None:
     ) -> None:
+        session_id = self._ensure_persisted_session(request)
         messages = self._build_initial_messages(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))
         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:
         while True:
             user_message = await queues.input.get()
             user_message = await queues.input.get()
             if user_message.role != "user" or user_message.name == "event_agent":
             if user_message.role != "user" or user_message.name == "event_agent":
                 messages.append(user_message)
                 messages.append(user_message)
                 continue
                 continue
             turn_index += 1
             turn_index += 1
+            turn_started_at = next_turn_started_at or self.clock()
+            next_turn_started_at = None
             await self._run_chat_turn(
             await self._run_chat_turn(
                 request,
                 request,
                 queues,
                 queues,
                 messages,
                 messages,
                 user_message,
                 user_message,
                 turn_index,
                 turn_index,
+                session_id,
+                turn_started_at,
             )
             )
 
 
     async def _run_chat_turn(
     async def _run_chat_turn(
@@ -316,10 +380,20 @@ class DebugRuntime:
         messages: list[ChatMessage],
         messages: list[ChatMessage],
         user_message: ChatMessage,
         user_message: ChatMessage,
         turn_index: int,
         turn_index: int,
+        session_id: str | None = None,
+        turn_started_at: float | None = None,
     ) -> None:
     ) -> None:
+        turn_started_at = turn_started_at or self.clock()
         messages.append(user_message)
         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 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
         event_loops = 0
         round_index = 0
         round_index = 0
@@ -341,22 +415,33 @@ class DebugRuntime:
             saw_event = False
             saw_event = False
             assistant_content: list[str] = []
             assistant_content: list[str] = []
             events: list[ToolCallEvent] = []
             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
                 request.event_agent.enabled_tools
                 if event_loops < request.event_agent.max_event_loops
                 if event_loops < request.event_agent.max_event_loops
                 else []
                 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(
             await self._audit(
                 queues,
                 queues,
                 "chat_round_started",
                 "chat_round_started",
+                turn_started_at=turn_started_at,
+                session_id=session_id,
                 turn_index=turn_index,
                 turn_index=turn_index,
                 round_index=round_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(
             await self._audit(
                 queues,
                 queues,
                 "chat_agent_request",
                 "chat_agent_request",
+                turn_started_at=turn_started_at,
+                session_id=session_id,
                 agent="chat_agent",
                 agent="chat_agent",
                 turn_index=turn_index,
                 turn_index=turn_index,
                 round_index=round_index,
                 round_index=round_index,
@@ -370,9 +455,26 @@ class DebugRuntime:
                 tools=[],
                 tools=[],
                 params=request.chat_agent,
                 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 item.kind == "message_delta":
                     if ttft_ms is None:
                     if ttft_ms is None:
                         ttft_ms = self._elapsed_ms(started_at)
                         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 "")
                     assistant_content.append(item.content or "")
                     await queues.output.put(
                     await queues.output.put(
                         {"type": "message_delta", "content": item.content}
                         {"type": "message_delta", "content": item.content}
@@ -399,15 +501,31 @@ class DebugRuntime:
                     await self._audit(
                     await self._audit(
                         queues,
                         queues,
                         "chat_event_detected",
                         "chat_event_detected",
+                        turn_started_at=turn_started_at,
+                        session_id=session_id,
                         turn_index=turn_index,
                         turn_index=turn_index,
                         round_index=round_index,
                         round_index=round_index,
                         event_id=event.id,
                         event_id=event.id,
                         event_name=event.name,
                         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(
             await self._audit(
                 queues,
                 queues,
                 "chat_agent_response",
                 "chat_agent_response",
+                turn_started_at=turn_started_at,
+                session_id=session_id,
                 agent="chat_agent",
                 agent="chat_agent",
                 turn_index=turn_index,
                 turn_index=turn_index,
                 round_index=round_index,
                 round_index=round_index,
@@ -419,23 +537,32 @@ class DebugRuntime:
                     "total_tokens": total_tokens,
                     "total_tokens": total_tokens,
                     "cached_tokens": cached_tokens,
                     "cached_tokens": cached_tokens,
                 },
                 },
+                raw_chunks=raw_chunks,
             )
             )
 
 
             if assistant_content or events:
             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:
             if events:
                 await queues.events.put(
                 await queues.events.put(
                     EventAgentRequest(
                     EventAgentRequest(
                         events=events,
                         events=events,
-                        history=list(messages),
+                        history=self._event_agent_history(messages),
                         system_prompt=request.event_agent.system_prompt,
                         system_prompt=request.event_agent.system_prompt,
                         extra_body=request.event_agent.extra_body,
                         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(
                 tool_replies = await self._wait_for_tool_replies(
@@ -449,6 +576,8 @@ class DebugRuntime:
                 await self._audit(
                 await self._audit(
                     queues,
                     queues,
                     "event_agent_completed",
                     "event_agent_completed",
+                    turn_started_at=turn_started_at,
+                    session_id=session_id,
                     turn_index=turn_index,
                     turn_index=turn_index,
                     round_index=round_index,
                     round_index=round_index,
                     event_names=[event.name for event in events],
                     event_names=[event.name for event in events],
@@ -457,6 +586,19 @@ class DebugRuntime:
                 )
                 )
 
 
             elapsed_ms = self._elapsed_ms(started_at)
             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(
             await queues.output.put(
                 {
                 {
                     "type": "round_stats",
                     "type": "round_stats",
@@ -473,6 +615,8 @@ class DebugRuntime:
             await self._audit(
             await self._audit(
                 queues,
                 queues,
                 "chat_round_finished",
                 "chat_round_finished",
+                turn_started_at=turn_started_at,
+                session_id=session_id,
                 turn_index=turn_index,
                 turn_index=turn_index,
                 round_index=round_index,
                 round_index=round_index,
                 had_event=saw_event,
                 had_event=saw_event,
@@ -489,9 +633,12 @@ class DebugRuntime:
         await self._audit(
         await self._audit(
             queues,
             queues,
             "turn_completed",
             "turn_completed",
+            turn_started_at=turn_started_at,
+            session_id=session_id,
             turn_index=turn_index,
             turn_index=turn_index,
             round_count=round_index,
             round_count=round_index,
         )
         )
+        self._complete_persisted_turn(session_id, turn_index)
         await queues.output.put(
         await queues.output.put(
             {
             {
                 "type": "turn_completed",
                 "type": "turn_completed",
@@ -512,7 +659,11 @@ class DebugRuntime:
             await self._audit(
             await self._audit(
                 queues,
                 queues,
                 "event_agent_request",
                 "event_agent_request",
+                turn_started_at=request.turn_started_at,
+                session_id=request.session_id,
                 agent="event_agent",
                 agent="event_agent",
+                turn_index=request.turn_index,
+                round_index=request.round_index,
                 params=self._params_snapshot(event_agent.params),
                 params=self._params_snapshot(event_agent.params),
                 events=self._event_snapshots(request.events),
                 events=self._event_snapshots(request.events),
                 history=self._message_snapshots(request.history),
                 history=self._message_snapshots(request.history),
@@ -536,8 +687,13 @@ class DebugRuntime:
             await self._audit(
             await self._audit(
                 queues,
                 queues,
                 "event_agent_response",
                 "event_agent_response",
+                turn_started_at=request.turn_started_at,
+                session_id=request.session_id,
                 agent="event_agent",
                 agent="event_agent",
+                turn_index=request.turn_index,
+                round_index=request.round_index,
                 replies=[reply.model_dump() for reply in replies],
                 replies=[reply.model_dump() for reply in replies],
+                raw_model_chunks=event_agent.raw_model_chunks(request.events),
             )
             )
             for reply in replies:
             for reply in replies:
                 await queues.input.put(reply)
                 await queues.input.put(reply)
@@ -609,6 +765,13 @@ class DebugRuntime:
             for message in messages
             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(
     async def _drain_input(
         self,
         self,
         queues: RuntimeQueues,
         queues: RuntimeQueues,
@@ -629,6 +792,97 @@ class DebugRuntime:
     def _elapsed_ms(self, started_at: float) -> int:
     def _elapsed_ms(self, started_at: float) -> int:
         return round((self.clock() - started_at) * 1000)
         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]:
     def _params_snapshot(self, params: AgentParams) -> dict[str, Any]:
         return params.model_dump()
         return params.model_dump()
 
 
@@ -642,9 +896,24 @@ class DebugRuntime:
         self,
         self,
         queues: RuntimeQueues,
         queues: RuntimeQueues,
         event: str,
         event: str,
+        *,
+        session_id: str | None = None,
+        turn_started_at: float | None = None,
         **details: Any,
         **details: Any,
     ) -> None:
     ) -> 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)
         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(
         await queues.output.put(
             {
             {
                 "type": "audit",
                 "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 dataclasses import dataclass
-from typing import Literal
+from typing import Any, Literal
 
 
 from pydantic import BaseModel, ConfigDict
 from pydantic import BaseModel, ConfigDict
 
 
@@ -30,6 +30,7 @@ class StreamItem:
     content: str | None = None
     content: str | None = None
     event: ToolCallEvent | None = None
     event: ToolCallEvent | None = None
     usage: TokenUsage | None = None
     usage: TokenUsage | None = None
+    raw_chunk: dict[str, Any] | None = None
 
 
     @classmethod
     @classmethod
     def message_delta(cls, content: str) -> "StreamItem":
     def message_delta(cls, content: str) -> "StreamItem":
@@ -39,6 +40,10 @@ class StreamItem:
     def usage_item(cls, usage: TokenUsage) -> "StreamItem":
     def usage_item(cls, usage: TokenUsage) -> "StreamItem":
         return cls(kind="usage", usage=usage)
         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:
 def _stream_event(cls: type[StreamItem], event: ToolCallEvent) -> StreamItem:
     return cls(kind="event", event=event)
     return cls(kind="event", event=event)

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

@@ -75,6 +75,7 @@ class OpenAICompatibleChatClient:
                     break
                     break
 
 
                 chunk = json.loads(data)
                 chunk = json.loads(data)
+                yield StreamItem.raw_response_chunk(chunk)
                 for item in parser.feed(chunk):
                 for item in parser.feed(chunk):
                     yield item
                     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 chatForm = document.querySelector("#chat-form");
 const userMessage = document.querySelector("#user-message");
 const userMessage = document.querySelector("#user-message");
 const runButton = document.querySelector("#run-button");
 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 snapshotName = document.querySelector("#workspace-snapshot-name");
 const savedSnapshots = document.querySelector("#saved-workspace-snapshots");
 const savedSnapshots = document.querySelector("#saved-workspace-snapshots");
 const systemPrompts = document.querySelector("#system-prompts");
 const systemPrompts = document.querySelector("#system-prompts");
@@ -12,6 +22,7 @@ const toolList = document.querySelector("#tool-list");
 const toolCount = document.querySelector("#tool-count");
 const toolCount = document.querySelector("#tool-count");
 const chatConfigDialog = document.querySelector("#chat-config-dialog");
 const chatConfigDialog = document.querySelector("#chat-config-dialog");
 const eventConfigDialog = document.querySelector("#event-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 WORKSPACE_SNAPSHOTS_STORAGE_KEY = "agent-lab.workspace-snapshots.v1";
 const LEGACY_PROMPT_SETS_STORAGE_KEY = "agent-lab.prompt-sets.v1";
 const LEGACY_PROMPT_SETS_STORAGE_KEY = "agent-lab.prompt-sets.v1";
 
 
@@ -23,6 +34,12 @@ let elapsedTimer = 0;
 let hasBackendRoundStats = false;
 let hasBackendRoundStats = false;
 let pendingEnabledTools = null;
 let pendingEnabledTools = null;
 let availableTools = [];
 let availableTools = [];
+let currentSessionId = null;
+let sessions = [];
+let auditEntries = [];
+let selectedAuditKey = null;
+let liveAuditSequence = 0;
+let turnActive = false;
 
 
 bindClick("#add-system-prompt", () => {
 bindClick("#add-system-prompt", () => {
   systemPrompts.append(createSystemPrompt({ content: "" }));
   systemPrompts.append(createSystemPrompt({ content: "" }));
@@ -35,6 +52,19 @@ bindClick("#add-pre-message", () => {
 bindClick("#save-workspace-snapshot", saveWorkspaceSnapshot);
 bindClick("#save-workspace-snapshot", saveWorkspaceSnapshot);
 bindClick("#load-workspace-snapshot", loadWorkspaceSnapshot);
 bindClick("#load-workspace-snapshot", loadWorkspaceSnapshot);
 bindClick("#delete-workspace-snapshot", deleteWorkspaceSnapshot);
 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("#open-chat-config-panel", openChatConfig);
 bindClick("#close-chat-config", () => {
 bindClick("#close-chat-config", () => {
   chatConfigDialog.close();
   chatConfigDialog.close();
@@ -58,6 +88,9 @@ chatForm.addEventListener("submit", (event) => {
 initializePromptList();
 initializePromptList();
 refreshWorkspaceSnapshotSelector();
 refreshWorkspaceSnapshotSelector();
 loadTools();
 loadTools();
+loadSessions({ preserveStatus: true });
+renderAuditReplay([]);
+renderSessionUsage({ calls: [], turns: [], session: {} });
 
 
 function openChatConfig() {
 function openChatConfig() {
   chatConfigDialog.showModal();
   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) {
 function renderTools(tools) {
   availableTools = tools;
   availableTools = tools;
   toolList.textContent = "";
   toolList.textContent = "";
@@ -622,21 +774,35 @@ function runDebugSession() {
   resetRun();
   resetRun();
   beginTurn(submittedMessage, "Connecting");
   beginTurn(submittedMessage, "Connecting");
   socket = new WebSocket(wsUrl());
   socket = new WebSocket(wsUrl());
-  socket.addEventListener("open", () => {
+  const activeSocket = socket;
+  activeSocket.addEventListener("open", () => {
+    if (socket !== activeSocket) {
+      return;
+    }
     statusEl.textContent = "Waiting for first token";
     statusEl.textContent = "Waiting for first token";
-    socket.send(JSON.stringify(buildRequest(submittedMessage)));
+    activeSocket.send(JSON.stringify(buildRequest(submittedMessage)));
     userMessage.value = "";
     userMessage.value = "";
   });
   });
-  socket.addEventListener("message", (event) => {
+  activeSocket.addEventListener("message", (event) => {
+    if (socket !== activeSocket) {
+      return;
+    }
     handleServerMessage(JSON.parse(event.data));
     handleServerMessage(JSON.parse(event.data));
   });
   });
-  socket.addEventListener("close", () => {
+  activeSocket.addEventListener("close", () => {
+    if (socket !== activeSocket) {
+      return;
+    }
+    socket = null;
     statusEl.textContent = "Idle";
     statusEl.textContent = "Idle";
     stopElapsedTimer();
     stopElapsedTimer();
     setRunState(false);
     setRunState(false);
     updateElapsed();
     updateElapsed();
   });
   });
-  socket.addEventListener("error", () => {
+  activeSocket.addEventListener("error", () => {
+    if (socket !== activeSocket) {
+      return;
+    }
     appendLog("error", "WebSocket connection failed");
     appendLog("error", "WebSocket connection failed");
   });
   });
 }
 }
@@ -645,6 +811,10 @@ function isSocketOpen() {
   return socket && socket.readyState === WebSocket.OPEN;
   return socket && socket.readyState === WebSocket.OPEN;
 }
 }
 
 
+function hasActiveTurn() {
+  return turnActive;
+}
+
 function beginTurn(submittedMessage, statusText) {
 function beginTurn(submittedMessage, statusText) {
   activeAssistant = null;
   activeAssistant = null;
   firstTokenAt = 0;
   firstTokenAt = 0;
@@ -659,6 +829,7 @@ function beginTurn(submittedMessage, statusText) {
 
 
 function buildRequest(submittedMessage = userMessage.value) {
 function buildRequest(submittedMessage = userMessage.value) {
   return {
   return {
+    session_id: currentSessionId || undefined,
     user_message: submittedMessage,
     user_message: submittedMessage,
     system_prompts: collectSystemPrompts(),
     system_prompts: collectSystemPrompts(),
     pre_messages: [...document.querySelectorAll(".pre-message")]
     pre_messages: [...document.querySelectorAll(".pre-message")]
@@ -694,6 +865,10 @@ function selectedTools() {
 
 
 function handleServerMessage(message) {
 function handleServerMessage(message) {
   if (message.type === "session_started") {
   if (message.type === "session_started") {
+    if (message.session_id) {
+      currentSessionId = message.session_id;
+      loadSessions({ preserveStatus: true });
+    }
     appendLog("session", "Session started");
     appendLog("session", "Session started");
     return;
     return;
   }
   }
@@ -714,7 +889,7 @@ function handleServerMessage(message) {
     return;
     return;
   }
   }
   if (message.type === "audit") {
   if (message.type === "audit") {
-    appendLog("audit", formatAuditMessage(message));
+    appendAuditEntry(message);
     return;
     return;
   }
   }
   if (message.type === "event") {
   if (message.type === "event") {
@@ -737,6 +912,10 @@ function handleServerMessage(message) {
     stopElapsedTimer();
     stopElapsedTimer();
     setRunState(false);
     setRunState(false);
     updateElapsed();
     updateElapsed();
+    if (currentSessionId) {
+      loadSessions({ preserveStatus: true });
+      loadSessionReplay(currentSessionId, { preserveStatus: true });
+    }
     return;
     return;
   }
   }
   if (message.type === "done") {
   if (message.type === "done") {
@@ -744,17 +923,14 @@ function handleServerMessage(message) {
     statusEl.textContent = "Idle";
     statusEl.textContent = "Idle";
     stopElapsedTimer();
     stopElapsedTimer();
     setRunState(false);
     setRunState(false);
+    if (currentSessionId) {
+      loadSessions({ preserveStatus: true });
+      loadSessionReplay(currentSessionId, { preserveStatus: true });
+    }
     closeSocket();
     closeSocket();
   }
   }
 }
 }
 
 
-function formatAuditMessage(message) {
-  const details = message.details && Object.keys(message.details).length
-    ? ` ${JSON.stringify(message.details)}`
-    : "";
-  return `${message.event}${details}`;
-}
-
 function appendAssistantDelta(content) {
 function appendAssistantDelta(content) {
   if (!firstTokenAt) {
   if (!firstTokenAt) {
     firstTokenAt = performance.now();
     firstTokenAt = performance.now();
@@ -781,6 +957,550 @@ function appendLog(kind, content) {
   messagesEl.scrollTop = messagesEl.scrollHeight;
   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) {
 function updateUsage(usage) {
   if (hasBackendRoundStats) {
   if (hasBackendRoundStats) {
     return;
     return;
@@ -829,6 +1549,7 @@ function stopElapsedTimer() {
 }
 }
 
 
 function setRunState(isRunning) {
 function setRunState(isRunning) {
+  turnActive = isRunning;
   runButton.disabled = isRunning;
   runButton.disabled = isRunning;
 }
 }
 
 
@@ -850,8 +1571,12 @@ function resetTurnStats() {
 }
 }
 
 
 function closeSocket() {
 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 charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <title>Agent Lab</title>
     <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>
   </head>
   <body>
   <body>
     <header class="topbar">
     <header class="topbar">
@@ -16,6 +16,23 @@
 
 
     <main class="layout">
     <main class="layout">
       <aside class="panel controls">
       <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">
         <section class="tools-section">
           <div class="section-head">
           <div class="section-head">
             <h2>Tools <span id="tool-count">0 selected</span></h2>
             <h2>Tools <span id="tool-count">0 selected</span></h2>
@@ -52,7 +69,33 @@
       </aside>
       </aside>
 
 
       <section class="workspace">
       <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 class="stats">
           <div><span>Tokens</span><strong id="stat-tokens">0</strong></div>
           <div><span>Tokens</span><strong id="stat-tokens">0</strong></div>
@@ -68,6 +111,31 @@
       </section>
       </section>
     </main>
     </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">
     <dialog id="chat-config-dialog" class="modal">
       <form method="dialog" class="modal-shell">
       <form method="dialog" class="modal-shell">
         <div class="modal-head">
         <div class="modal-head">
@@ -164,6 +232,6 @@
       </div>
       </div>
     </template>
     </template>
 
 
-    <script src="/static/app.js?v=20260706-workspace-snapshot"></script>
+    <script src="/static/app.js?v=20260707-session-switch-idle-socket"></script>
   </body>
   </body>
 </html>
 </html>

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

@@ -9,8 +9,10 @@
 }
 }
 
 
 body {
 body {
+  height: 100vh;
   margin: 0;
   margin: 0;
   min-height: 100vh;
   min-height: 100vh;
+  overflow: hidden;
 }
 }
 
 
 button,
 button,
@@ -75,6 +77,8 @@ button:disabled {
   gap: 16px;
   gap: 16px;
   grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
   grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
   height: calc(100vh - 56px);
   height: calc(100vh - 56px);
+  min-height: 0;
+  overflow: hidden;
   padding: 16px;
   padding: 16px;
 }
 }
 
 
@@ -86,6 +90,7 @@ button:disabled {
 }
 }
 
 
 .controls {
 .controls {
+  min-height: 0;
   overflow: auto;
   overflow: auto;
   padding: 16px;
   padding: 16px;
 }
 }
@@ -128,6 +133,21 @@ h2 {
   grid-template-columns: repeat(2, minmax(0, 1fr));
   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 {
 label {
   display: grid;
   display: grid;
   gap: 6px;
   gap: 6px;
@@ -256,17 +276,81 @@ textarea {
 .workspace {
 .workspace {
   display: grid;
   display: grid;
   grid-template-rows: minmax(0, 1fr) auto auto;
   grid-template-rows: minmax(0, 1fr) auto auto;
+  min-height: 0;
   min-width: 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 {
 .messages {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   gap: 10px;
   gap: 10px;
+  min-height: 0;
   overflow: auto;
   overflow: auto;
   padding: 16px;
   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 {
 .message {
   border-radius: 8px;
   border-radius: 8px;
   line-height: 1.5;
   line-height: 1.5;
@@ -309,6 +393,321 @@ textarea {
   font-size: 13px;
   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 {
 .error {
   background: #fef2f2;
   background: #fef2f2;
   border: 1px solid #fecaca;
   border: 1px solid #fecaca;
@@ -344,6 +743,13 @@ textarea {
   width: 760px;
   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 {
 .modal::backdrop {
   background: rgba(17, 24, 39, 0.36);
   background: rgba(17, 24, 39, 0.36);
 }
 }
@@ -355,12 +761,26 @@ textarea {
   padding: 16px;
   padding: 16px;
 }
 }
 
 
+.audit-modal .modal-shell {
+  grid-template-rows: auto minmax(0, 1fr);
+  height: 100%;
+  max-height: none;
+  overflow: hidden;
+}
+
 .modal-head {
 .modal-head {
   align-items: center;
   align-items: center;
   display: flex;
   display: flex;
   justify-content: space-between;
   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 {
 .stats {
   border-top: 1px solid #e4e9ee;
   border-top: 1px solid #e4e9ee;
   display: grid;
   display: grid;
@@ -393,19 +813,40 @@ textarea {
 }
 }
 
 
 @media (max-width: 860px) {
 @media (max-width: 860px) {
+  body {
+    height: auto;
+    overflow: auto;
+  }
+
   .layout {
   .layout {
     grid-template-columns: 1fr;
     grid-template-columns: 1fr;
     height: auto;
     height: auto;
+    overflow: visible;
   }
   }
 
 
   .workspace {
   .workspace {
     min-height: 70vh;
     min-height: 70vh;
+    overflow: visible;
   }
   }
 
 
   .stats,
   .stats,
   .composer,
   .composer,
   .pre-message,
   .pre-message,
+  .workspace-body,
+  .audit-modal-body,
+  .usage-summary,
   .button-row {
   .button-row {
     grid-template-columns: 1fr;
     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 pathlib import Path
 from typing import Any
 from typing import Any
 
 
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
 from fastapi.responses import FileResponse
 from fastapi.responses import FileResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 from pydantic import ValidationError
 from pydantic import ValidationError
@@ -12,9 +12,11 @@ from pydantic import ValidationError
 from agent_lab.application.contracts import DebugRunRequest
 from agent_lab.application.contracts import DebugRunRequest
 from agent_lab.application.queues import RuntimeQueues
 from agent_lab.application.queues import RuntimeQueues
 from agent_lab.application.runtime import DebugRuntime
 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.application.tools import ToolRegistry, build_default_tool_registry
 from agent_lab.domain.messages import ChatMessage
 from agent_lab.domain.messages import ChatMessage
 from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
 from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
+from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
 from agent_lab.settings import Settings
 from agent_lab.settings import Settings
 
 
 
 
@@ -31,9 +33,11 @@ def create_app(
 ) -> FastAPI:
 ) -> FastAPI:
     resolved_settings = settings or Settings()
     resolved_settings = settings or Settings()
     tool_registry = build_default_tool_registry()
     tool_registry = build_default_tool_registry()
+    session_store = SQLiteSessionStore(resolved_settings.database_path)
     resolved_runtime_factory = runtime_factory or _runtime_factory(
     resolved_runtime_factory = runtime_factory or _runtime_factory(
         resolved_settings,
         resolved_settings,
         tool_registry,
         tool_registry,
+        session_store,
     )
     )
 
 
     app = FastAPI(title="Agent Lab")
     app = FastAPI(title="Agent Lab")
@@ -51,6 +55,42 @@ def create_app(
     async def tools() -> list[dict[str, Any]]:
     async def tools() -> list[dict[str, Any]]:
         return tool_registry.available_tools()
         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")
     @app.websocket("/ws/debug")
     async def debug(websocket: WebSocket) -> None:
     async def debug(websocket: WebSocket) -> None:
         await websocket.accept()
         await websocket.accept()
@@ -142,6 +182,7 @@ def _parse_upstream_message(payload: Any) -> ChatMessage:
 def _runtime_factory(
 def _runtime_factory(
     settings: Settings,
     settings: Settings,
     tool_registry: ToolRegistry,
     tool_registry: ToolRegistry,
+    session_store: SessionStore,
 ) -> Callable[[], DebugRuntime]:
 ) -> Callable[[], DebugRuntime]:
     def build_runtime() -> DebugRuntime:
     def build_runtime() -> DebugRuntime:
         chat_client = OpenAICompatibleChatClient(
         chat_client = OpenAICompatibleChatClient(
@@ -151,11 +192,25 @@ def _runtime_factory(
             request_timeout_seconds=settings.request_timeout_seconds,
             request_timeout_seconds=settings.request_timeout_seconds,
             include_usage=settings.openai_include_usage,
             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
     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:
 async def _close_runtime(runtime: Any) -> None:
     runtime_close = getattr(runtime, "aclose", None)
     runtime_close = getattr(runtime, "aclose", None)
     if runtime_close is not 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_default_model: str = "gpt-4.1-mini"
     openai_include_usage: bool = True
     openai_include_usage: bool = True
     request_timeout_seconds: float = 60.0
     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.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
 from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
+from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
 
 
 
 
 def _runtime_queues_class():
 def _runtime_queues_class():
@@ -121,10 +122,37 @@ class FakeChatClient:
         params: AgentParams,
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
     ) -> AsyncIterator[StreamItem]:
         if tools:
         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)
             yield _event_tool_call_from_tools(tools, messages)
             return
             return
         self.calls += 1
         self.calls += 1
         if 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(
             yield StreamItem.event(
                 ToolCallEvent(
                 ToolCallEvent(
                     id="call_1",
                     id="call_1",
@@ -140,6 +168,16 @@ class FakeChatClient:
         yield StreamItem.message_delta("final answer")
         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:
 class StrictHistoryChatClient:
     def __init__(self) -> None:
     def __init__(self) -> None:
         self.calls = 0
         self.calls = 0
@@ -152,10 +190,37 @@ class StrictHistoryChatClient:
         params: AgentParams,
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
     ) -> AsyncIterator[StreamItem]:
         if tools:
         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)
             yield _event_tool_call_from_tools(tools, messages)
             return
             return
         self.calls += 1
         self.calls += 1
         if 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(
             yield StreamItem.event(
                 ToolCallEvent(
                 ToolCallEvent(
                     id="call_1",
                     id="call_1",
@@ -286,10 +351,41 @@ class EventRoundStatsChatClient:
         params: AgentParams,
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
     ) -> AsyncIterator[StreamItem]:
         if tools:
         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)
             yield _event_tool_call_from_tools(tools, messages)
             return
             return
         self.calls += 1
         self.calls += 1
         if 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(
             yield StreamItem.event(
                 ToolCallEvent(
                 ToolCallEvent(
                     id="call_1",
                     id="call_1",
@@ -303,12 +399,52 @@ class EventRoundStatsChatClient:
             )
             )
             return
             return
 
 
+        yield StreamItem.raw_response_chunk(
+            {
+                "choices": [
+                    {
+                        "delta": {"content": "final answer"},
+                        "finish_reason": None,
+                    }
+                ]
+            }
+        )
         yield StreamItem.message_delta("final answer")
         yield StreamItem.message_delta("final answer")
         yield StreamItem.usage_item(
         yield StreamItem.usage_item(
             TokenUsage(prompt_tokens=4, completion_tokens=6, total_tokens=10)
             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:
 class TwoTurnSessionChatClient:
     def __init__(self) -> None:
     def __init__(self) -> None:
         self.calls = 0
         self.calls = 0
@@ -445,6 +581,8 @@ async def test_runtime_emits_audit_events_and_backend_logs(caplog):
         "chat_round_finished",
         "chat_round_finished",
         "chat_round_started",
         "chat_round_started",
         "chat_agent_request",
         "chat_agent_request",
+        "chat_message_stream_started",
+        "chat_message_stream_finished",
         "chat_agent_response",
         "chat_agent_response",
         "chat_round_finished",
         "chat_round_finished",
         "session_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
     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
 @pytest.mark.asyncio
 async def test_runtime_audit_includes_model_params_prompts_results_and_usage():
 async def test_runtime_audit_includes_model_params_prompts_results_and_usage():
     request = DebugRunRequest(
     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"
         message for message in audits if message["event"] == "chat_agent_response"
     )
     )
     assert chat_response["details"]["event_names"] == ["handoff_note"]
     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"] == {
     assert chat_response["details"]["usage"] == {
         "prompt_tokens": 3,
         "prompt_tokens": 3,
         "completion_tokens": 0,
         "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 event_response["details"]["replies"][0]["role"] == "tool"
     assert '"tool": "handoff_note"' in event_response["details"]["replies"][0]["content"]
     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
 @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),
         chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
         event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
         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))
     runtime = DebugRuntime(RoundStatsChatClient(), clock=lambda: next(ticks))
 
 
     outputs = [message async for message in runtime.run(request)]
     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),
         chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
         event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
         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()
     client = EventRoundStatsChatClient()
     runtime = DebugRuntime(client, clock=lambda: next(ticks))
     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 client.calls == 4
     assert "Available events:" in client.messages_by_call[0][0].content
     assert "Available events:" in client.messages_by_call[0][0].content
     assert "Available events:" in client.messages_by_call[2][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"]
         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(
         yield StreamItem.event(
             ToolCallEvent(
             ToolCallEvent(
                 id="llm_call_1",
                 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 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
 @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.queues import RuntimeQueues
 from agent_lab.application.runtime import DebugRuntime
 from agent_lab.application.runtime import DebugRuntime
 from agent_lab.domain.events import ToolCallEvent
 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.chat_client import OpenAICompatibleChatClient
+from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
 from agent_lab.presentation.web import create_app
 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:
 class FakeRuntime:
@@ -181,6 +189,108 @@ def test_api_tools_returns_handoff_note_metadata():
     assert tools[0]["parameters"]["required"] == ["message"]
     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():
 def test_websocket_debug_streams_runtime_messages():
     runtime = FakeRuntime()
     runtime = FakeRuntime()
     app = create_app(runtime_factory=lambda: runtime)
     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 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
 @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
     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():
 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()
     html = Path("src/agent_lab/presentation/static/index.html").read_text()
     js = Path("src/agent_lab/presentation/static/app.js").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()
     css = Path("src/agent_lab/presentation/static/styles.css").read_text()
 
 
     assert 'message.type === "audit"' in js
     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():
 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 "function isSocketOpen()" in js
     assert "socket.send(JSON.stringify({ type: \"user_message\"" 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 === "turn_completed"' in js
     assert 'message.type === "done"' 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():
 def test_static_workspace_snapshot_uses_stable_storage_hooks():
     js = Path("src/agent_lab/presentation/static/app.js").read_text()
     js = Path("src/agent_lab/presentation/static/app.js").read_text()