Session Debug Workspace — TDD implementation plan
Eight bite-sized tasks, each a failing-test-first cycle ending in its own commit. Phase A ships user value with read-side changes only; Phase B makes the turn record canonical.
This page is the overview; the .md is the executable plan. Every task in the md contains the actual failing test code, the exact run command with expected failure output, the minimal implementation, and the commit — per the writing-plans discipline. Workers execute the md task-by-task; this page exists so reviewers can hold the shape in one screen.
Global constraints (bind every task)
- Additive-only schema: new
turns.jsonlfields optional; missingschema_version⇒ v1; readers ignore unknown fields. ONE normalized reader server-side. - Slot values in any NEW field go through
_slot_write_repr— never raw (Call Replay Atlas PII rule adopted verbatim). - Eval rows NEVER in
logs/turns.jsonl— run-scopedlogs/evals/<run_id>/turns.jsonlonly. - No new LLM calls, threads, or dependencies; no turn-path latency beyond list appends.
guards_evaluatedand verdict candidates capped at 24 (matches the emitter cap instate_manager.py:909).- Commit per task — no batching.
The ten tasks
| Task | Creates / modifies | Test seam | Produces (contract for later tasks) | |
|---|---|---|---|---|
| A1 | Durable slot_write + transition_verdict | state_trace_writer.py filter set | install_writer(tmp) → emit → assert persisted; noisy type still excluded | state_trace.jsonl carries attribution events as they happen (the durable file alone doesn't change atlas output — Task 1b closes that) |
| A1b | Lossless teardown dump | scripts/call_replay.py:300 (dump_session_bus_events) | seeded durable trace + rolled ring → merged dump, deduped by (seq, ts, type), other sessions excluded | atlas SLOTS/VERDICTS lanes stop truncating on long calls; signature gains optional trace_path, phone-server call site unchanged |
| A2 | Pure merge module | new riff/session_debug.py | plain-dict fixtures, no I/O: turn-window grouping (by turn_index, else turn_complete boundaries; pre-2b live sessions fall back to first turn — stated behavior), B-016 zero-value guard, session isolation, caps | build_session_debug(turn_rows, trace_events, required) → {turns: [...]} incl. required_slots_in_state; required_by_state(flow) |
| A2b | Live turn_complete emission | riff/events.py helper + one call in live/session.py _record_live_turn_result | subscribe → emit_turn_complete("s", 4) → assert session_id/turn_index/source on the bus | live calls get real turn boundaries for the window grouping (cascade + locked-choice paths already emit; live path didn't) |
| A3 | HTTP endpoint | web_server.py (+ extracting shared readers from the two existing endpoints) | HTTPServer+RiffHandler fixture (pattern: test_state_trace_api.py): 400 / 404 / merged-shape contract; regression guard on refactored endpoints | GET /api/debug/session_facts?session_id=X; sources: turns.jsonl + state_trace.jsonl + bus buffer + bus_events.jsonl, deduped by seq |
| A4 | flow-studio inspector | new web/session-facts-view.js (pure model); flow-studio.js/.html, flow-studio-replay.js (render only) | node-driven contract test on the pure model (incl. volunteered-extra-slot exclusion); Playwright smoke with screenshot on the PR | stateFactsModel(stateId, facts, turnIndex) → triad + guards + verdict; triad built ONLY from required_slots_in_state; panel renders from the model only |
| B5 | Turn-scoped event collector | new riff/turn_events.py | private EventBus: only target session + relevant types collected; unsubscribes on exit; explicit start/close idempotent across async handlers | TurnEventCollector(session_id) — explicit start()/close() primary (live opens and closes in different handlers), context-manager sugar for sync callers |
| B6 | Dual-source slot diff | riff/turn_events.py | pure fn: attributed pass-through; direct mutation ⇒ unattributed_diff (redacted); dict-valued slots NOT skipped; same-slot direct mutation AFTER an attributed write caught via final-shape reconciliation | reconcile_slot_diff(before, after, events) |
| B7 | TurnRecord v2 builder | turn_logger.py (extract builder); call sites in live/session.py, web_server.py | SimpleNamespace fakes: v1 fields intact + schema_version: 2; enrichments only when inputs supplied; caps; then full suite | build_turn_record(result, ctx, *, state_snapshot, turn_events, slots_before, kind, run_id); log_turn stays backward compatible. Collector opens at user-turn start — before _on_tool_call can mutate slots mid-stream (both _record_live_turn_result and top-of-_on_turn_complete are too late) — and closes on every completion path incl. locked-choice short circuits |
| B8 | Eval sink + tolerant reader | new riff/turns_reader.py; flow_eval/driver.py; web_server.py | v1/v2/garbage fixture file; unit test on the extracted _write_eval_turn_record seam ⇒ run-scoped rows, global log untouched, per-turn events not cumulative | read_turn_rows(path, session_id); run_conversation(..., turn_log_dir) slices events[event_mark:] per turn and writes kind="eval" + run_id via the seam |
Key decisions baked into the tasks (from the codex consult)
- Phase A before TurnRecord v2 (codex Q10): the merge endpoint proves the record shape against real debugging before the shape gets frozen into a writer. Stop-and-evaluate point after Task 4.
- slot_diff is dual-source (Q2):
slot_writeevents carry attribution but are incomplete (directsession.slotsmutations exist; the tool-diff lane skips dict values). The before/after diff guarantees completeness; misses are labeledunattributed_diff, turning write-discipline drift into a burn-down list. - Embed AND persist (Q3): compact summaries in the turn record answer "why did it ask twice" from one row; durable trace events power per-state aggregation. Duplication is cheaper than joins against missing artifacts.
missing_required_slotscomes from the caller-supplied post-transition snapshot (Q4):log_turnnever infers FSM state from hidden globals; pre-transition missing-lists are misleading.- Extend flow-studio's own panel (Q5):
state-trace-panel.jsis visit-oriented and styled for voice.html — whole-panel transplant would rot. - Run-scoped eval sink (Q1):
kind=evalmetadata alone doesn't protect the live log from bloat or the session index from pollution.
Execution order
Tasks 1→1b→2→2b→3→4 ship Phase A on their own (1b and 2b are the two write-side touches: teardown-dump merge, live boundary emit). Tasks 5→6→7→8 are strictly ordered among themselves but independent of Task 4. After Task 4: use the inspector on real calls for a few days before Phase B — /api/debug/session_facts is the contract TurnRecord v2 must serve.