Repository navigation
feat(recording): Session Recording — full feature (slices 2–7, #467) - #472
Merged
Juliusolsson05 merged 9 commits intoJul 7, 2026
Merged
Conversation
…bucket Slice 2 of Session Recording (#467). A continuous recorder streaming every SessionFeed event re-opens the exact uncapped-growth wound that OOM'd the app on 2026-07-04 (#388) — an unbudgeted disk root that grows forever across app runs. Register `session-recordings/` in debugRetention.ts as a first-class budgeted bucket (12% cap lane), so the per-recording 128 MiB file cap is backstopped by a directory-level budget. Recordings are collected as whole-folder dir artifacts (collectSessionRecording Dirs), so removeArtifact's `rm -rf` branch evicts a recording atomically — never file-by-file, which would orphan meta.json or events.jsonl into an unloadable half-recording. Ordered by folder mtime so the active-grace guard protects a live recording that is still being written. Colocated test pins the folder-atomic collection invariant (the exact bug this shape prevents). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slice 3 of Session Recording (#467, plan §7/§7b). Wires the recorder manager (constructed in main/index.ts, previously reachable only via the outbound observer) to a user-invocable command. Main IPC (folded into devDebug.ts, gated by isSessionRecordingEnabled): record-session:reserve-note (sessionId) -> reserveNote, returns noteId|null record-session:fill-note (sessionId, noteId, text) -> fillNote sessionRecorders is threaded through IpcDeps like every other manager. Both handlers double-gate: the flag is the trust boundary AND the manager is null in a normal build, so they are no-ops unless recording is on. Preload: reserveRecordingNote / fillRecordingNote (flow into window.api via the existing typeof-api inference — no separate type file). Renderer: "Attach Recording Note" command (sessionCommands.ts, surface 'debug'), enabled only when flags.sessionRecordingEnabled. Per plan §7b it RESERVES the marker instantly on invoke (pins the reaction tick) BEFORE opening the note input; fill happens on submit. Cancel leaves the reserved marker (a bare timestamp is still a useful flag). The input reuses the debug- bundle note modal (DebugBundleNotePrompt), generalized with optional label props + a new recordingNotePrompt store slice mirroring debugBundleNotePrompt. sessionRecordingEnabled is threaded through the command flags exactly like devDebugEnabled. No dedicated keybinding (plan calls it a nice-to-have) — the command lives in the palette; a reflex shortcut can be added later via useKeybinds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Redaction is the one piece of session recording that can leak: a local recording captures full tool args/results/file contents/prompts. Before a recording can become a checked-in fixture it must be scrubbed. New rendering/replay/redact.ts (pure, importable, unit-tested) with two modes: - 'full-text-capped': keep prose but truncate to the bundle corpus tiers (360/600/8000) and strip any SENSITIVE_KEY-matched value. - 'structure-only' (the innovation): drop every free-text body, keep only the identity/structure the pipeline keys on (message ids, tool_use ids, timestamps, turn ids, block kinds, status, uuids, roles). Free strings become a length-preserving placeholder, so a structure-only recording still exercises every ownership/ordering rule while carrying ~no sensitive text. SENSITIVE_KEY is now exported from unknowns.ts as the single source of truth so the redactor, the hard gate, and shapePathsOf can never drift. New scripts/extract-rendering-recordings.mjs (successor to extract-rendering-fixtures.mjs) + tsx bridge extract-recording-core.mts: read a local recording folder, redact + minimize (drop the provably-ignored screen channel) + optionally window around __note markers, and HARD-GATE — refuse to emit any fixture that still contains a SENSITIVE_KEY value. The gate lives in the pure core (findSensitiveSurvivors) so the .mjs stays a thin IO wrapper with no second copy of the regex to drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New rendering/__tests__/recordingCorpus.test.ts (mirrors bundleCorpus.test.ts, Mode 1 golden replay per plan §6): for each redacted recording fixture, replay the SessionFeed channel stream through the REAL pipeline and assert the rows are STABLE against the checked-in golden. BLESS-gated by AGENT_CODE_RECORDING_BLESS; any un-tolerated change fails loudly. Unlike the bundle corpus there is no external legacy ground truth — the golden IS the pipeline's own last-blessed output, so bless rewrites `expected` and triage is normally empty (kept for symmetry / knowingly-tolerated divergence). The suite self-skips (describe.skipIf) when the fixtures dir is empty/absent so CI stays green until real recordings land, and is SEEDED with two hand-built fixtures (committed plane + semantic plane) so it actually runs today. Replay is INLINE for now: reconstructSlices folds recorded events straight into RuntimeLedgerSlices — a small local stand-in for slice 4's shared RecordedSessionFeed harness (built in parallel on another branch). A later integration commit swaps the reconstruction for the shared harness with no fixture changes, since fixtures speak the channel stream, not the reconstruction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replay a captured Session Recording (meta.json + events.jsonl, the 9
SessionFeed channels) tick-by-tick through the REAL rendering pipeline and
yield per-tick { ledger, rows, feedItems }.
- reconstructSlices.ts folds the recorded events back into RuntimeLedgerSlices
by REUSING the production leaf reducers (foldSemanticEvent, reduceStreamPhase,
ghostsFromSemanticTurn, reconcileUpstream, the provider transcript mappers) —
not a second fold. Only the thin orchestration glue is mirrored, cited to the
useIpcSubscriptions sites it mirrors.
- recordedSession.ts constructs the adapter + ledger ONCE and drives them each
tick, so the D11 identity caches are genuinely exercised. Because the running
state is held across ticks and the reducers are reference-stable on no-op,
unchanged planes hand back the SAME object reference and no-op ticks return
the cached ledger — the reference pattern is faithful by construction (no
content-pool needed).
- onBeforeFold clock seam lets a test pin Date.now() to the recorded wall clock
(via vi.setSystemTime) without adding a clock seam to runtime code.
Fidelity: reducer-faithful + orchestration-mirrored, driven from the node
`unit` project (the React fold hook is unreachable there). Documented in the
module header, including which fold-glue paths are out of scope.
Round-trip test: hand-built prompt→tool→committed-handoff→completion→exit
recording; asserts rows per tick, the D11 same-object contract on no-op ticks,
torn-tail tolerance, and __note/__truncated skipping.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
assertInvariants(replayResult) checks all four ledger invariants at EVERY tick, with no hand-authored expected output — the permanent successor to the dev-only render shadow (plan §6 Mode 2): 1. single-owner / dual-render: no two selected rows own the same artifact (row id, toolUseId, turn+block, or exact textKey). 2. no-vanish-without-replacement: a row gone at N+1 must be explained — a rejection decision names it, a replacement covers its artifact, OR its live semantic turn was torn down (exit) and thus left the runtime entirely. 3. no-unexplained-shrink: aggregate signal when the selected row count drops with unexplained drops. 4. D11 reference stability: a tick whose adapter inputs are reference-identical MUST return the same RenderLedger object (adapter.test.ts:119-149 over a real stream). Lifecycle work/empty rows are excluded from single-owner and vanish checks, mirroring bundleCorpus scopeUnits (they toggle on every phase edge). Test: green over the realistic prompt→tool→completion→exit recording (with an assertion that the tool→committed handoff actually happened, so "no violations" isn't vacuous), plus one negative control per invariant proving each fires. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eat/session-recording-full
…eat/session-recording-full
Slices 4 and 7 were built in parallel; the golden corpus (slice 7) shipped a local reconstructSlices stand-in while slice 4 built the canonical reducer-faithful replayRecording. Integration swaps the corpus onto the shared harness (higher fidelity, one reconstruction path, no duplicate fold). The swap surfaced a real fixture bug: hand-semantic-turn.json jumped to block_completed with no block_started, which C's stand-in tolerated but the REAL foldEvent reducer correctly ignores (block_completed no-ops when the block was never opened - foldEvent.ts:668). Repaired the fixture into a valid semantic stream and re-blessed against the real reducers' output; both hand fixtures now paint their intended units (user row + semantic turn) with empty triage. Verified green without bless. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes the Session Recording feature on top of slice 1 (#471). Plan:
docs/rendering/session-recording-plan-2026-07.md. Continuous, debug-gated capture of a session's rendering-input stream, replayable deterministically through the real pipeline. Built as three parallel workstreams, integrated onto one branch.What's in it
Slice 2 — retention.
session-recordings/is a budgeteddebugRetentionbucket (12% lane); prune evicts whole recording folders oldest-first (never file-by-file, so a recording is always atomic). Closes the #388 disk-panic vector. Folder-atomic prune unit-tested.Slice 3 — controls + Attach Recording Note. Gated IPC (
record-session:reserve-note/fill-note) → preload → the Attach Recording Note command (surfacedebug, only shown when recording is active). Per your spec: reserves the marker instantly on invoke (pins the reaction tick), then opens the note input (reuses the debug-bundle modal); fill on submit; cancel keeps the reserved marker.Slice 4 — replay harness.
replay/recordedSession.ts+reconstructSlices.ts: replays a recording tick-by-tick through the real leaf reducers (foldSemanticEvent,reduceStreamPhase, ghost reducers, provider mappers) → adapter → ledger → view bridge. Adapter/ledger constructed once and fed each tick, so D11 reference identity is real (verified: a no-op tick returns the sameRenderLedgerobject). Reducer-faithful fidelity (node-project constraint documented; the React fold's own glue is the only out-of-scope layer).Slice 5 — invariant replay.
assertInvariantsover every tick: single-owner, no-vanish-without-replacement, no-unexplained-shrink, D11 reference stability. Each with a negative control.Slice 6 — redaction.
replay/redact.ts:full-text-capped(truncate to corpus tiers, stripSENSITIVE_KEYvalues) andstructure-only(drop every free-text body → length-preserving placeholder, keep the ids/kinds/timestamps/status the pipeline keys on) — a structure-only recording exercises every ownership/ordering rule with ~no sensitive text. Extraction scriptextract-rendering-recordings.mjswith a hard gate that refuses to emit any fixture where a secret survived; note-windowing centres fixtures on__notemarkers.Slice 7 — golden corpus.
recordingCorpus.test.tsmirrorsbundleCorpus(bless-gatedAGENT_CODE_RECORDING_BLESS=1, stability-not-parity, fails loud, self-skips until real recordings land). Seeded with two hand-built fixtures. Unified onto slice 4's shared harness at integration — which surfaced a real fixture bug (ablock_completedwith noblock_started, which the real reducer correctly ignores); fixture repaired into a valid stream and re-blessed against the real reducers.Verification
Full two-step gate (node refs then web) 0 errors; full unit suite 424 passing (the one failing file is the pre-existing empty voice-dictation submodule test). No new
test:*scripts.Try it
AGENT_CODE_DEV_DEBUG=1 AGENT_CODE_SESSION_RECORD=1 npm run dev→ each session writes~/.config/agent-code/session-recordings/<recordingId>/{meta.json,events.jsonl}. Run Attach Recording Note from the palette when you see a bug.🤖 Generated with Claude Code