Skip to content

feat(recording): Session Recording — full feature (slices 2–7, #467) - #472

Merged
Juliusolsson05 merged 9 commits into
integration/rendering-pipelinefrom
feat/session-recording-full
Jul 7, 2026
Merged

Juliusolsson05 merged 9 commits into
integration/rendering-pipelinefrom
feat/session-recording-full

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

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 budgeted debugRetention bucket (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 (surface debug, 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 same RenderLedger object). Reducer-faithful fidelity (node-project constraint documented; the React fold's own glue is the only out-of-scope layer).

Slice 5 — invariant replay. assertInvariants over 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, strip SENSITIVE_KEY values) and structure-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 script extract-rendering-recordings.mjs with a hard gate that refuses to emit any fixture where a secret survived; note-windowing centres fixtures on __note markers.

Slice 7 — golden corpus. recordingCorpus.test.ts mirrors bundleCorpus (bless-gated AGENT_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 (a block_completed with no block_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

Juliusolsson05 and others added 9 commits July 7, 2026 16:19
…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>
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>
@Juliusolsson05
Juliusolsson05 merged commit 59943d8 into integration/rendering-pipeline Jul 7, 2026
@Juliusolsson05
Juliusolsson05 deleted the feat/session-recording-full branch July 7, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant