feat(chat): render resumed transcript from ACP replay (flag, off) - #8862
feat(chat): render resumed transcript from ACP replay (flag, off)#8862CrysisDeu wants to merge 1 commit into
Conversation
833fa5b to
71a2506
Compare
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of I have what I need for the design assessment. Design-Verdict: CONCERNS Sound, measured, flag-off prototype — but its load-bearing prompt-text matcher stays permanent surface unless identity anchors the default engine doesn't emit ever land. WatchThe merge's correctness rests on string-level knowledge of every wrapper the prompt writer produces (containment + dash-folding + Suggestions
[DESIGN-REVIEWED] 7492782 |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of UX-Verdict: CONCERNS The toggle and banner both read cleanly cold; the banner's two-source counts and clickable-looking tag leave the reader unsure what to trust or touch. Watch
[UX-REVIEWED] 7492782 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks complete. Final review: First-Principles-Verdict: CONCERNS
Not justified as shipped
What this change shipsInventory (10 items) — 8 justifiedIntent: let a resumed session show the agent's own record (thinking, tool detail) instead of the lossy JSONL reconstruction — an ADDITION (prototype flag, default off). The change has more than 10 items; these are the 10 most noticeable (docs/RFC/spec rows, screenshots, and two small helper exports are workflow- or invariant-mandated riders).
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 7492782 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've verified the single candidate against the actual caller structure. Candidate 1 (ledger undercount on re-arm) requires No findings. [OPUS-REVIEWED] 7492782 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — 🔴 changes requested (blocking)GPT 5.6 found at least one blocking issue that must be resolved before merging This comment is updated in place on each push. BLOCKING -- website/src/store/chatSlice.ts:2785 -- Complete replay views lose corpus identity [BLOCK-MERGE] 7492782 Adjudication (Opus 4.8) — is blocking on each finding proportionate?I have enough to rule. Let me record my analysis. The adjudicable block is empty (0 findings). One fenced finding, F1. F1 analysis ( For a FLAG I need a complete record: every condition at Verdict: UPHOLD-FENCED (when torn, UPHOLD-FENCED). False positive or not applicable? A repository writer can comment: |
71a2506 to
e111004
Compare
e111004 to
d5710f9
Compare
d5710f9 to
1d94087
Compare
1d94087 to
5f22961
Compare
5135ec6 to
33b44ac
Compare
33b44ac to
09edc92
Compare
09edc92 to
6354ab6
Compare
|
DOC ADDED Design rationale RFC for this flag: problem (dual-written transcript, replay counted-and-dropped), the seen-by-the-agent vs. not dividing line, phasing P0 (this PR) → P1 ( Note for the code owner: this was amended onto |
|
@CrysisDeu — real-device pass of
Minor: the approval Unrelated but seen on the pod: with Evidence (screens + |
6354ab6 to
c4362a7
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review: not approving.
The dashboard.replay_from_acp flag verifies genuinely real and default-false (sections.py, loader.py, config-baseline.json all agree), and I found no cross-session transcript exposure in the capture/pop lifecycle. This is not a security hold.
Holding because of your own real-device testing comment (2026-09-06T08:55Z) reporting three reproducible rendering bugs, of which two are still unfixed at the current head:
- Bug #3: engine-internal
fetch_cloud_configtool rows still surface after the last replayed answer on the kas/v3 backend — the only filter is pre-first-prompt, andtest_tool_result_without_call_is_kept_visibledeliberately pins orphan tool rows as visible, so this needs a real fix, not just a test update. - The kas steer-as-its-own-prompt half of bug #1:
_is_prompt_rowstill returnsFalsefor steer rows.
Please close bug #3 and the steer half of bug #1 before this lands, even behind the flag — a self-reported user-visible leak of internal tool calls into a transcript shouldn't ship even opt-in.
|
Do not merge yet |
|
@CrysisDeu Thanks for keeping this one alive. I re-audited it today (audited at Good news first: none of this feature has landed on What still blocks it is the scope, not the goal:
Narrowing to the flagged read path plus fixing 1 and 2 would make this reviewable. Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
|
span=60eee5df9fed
self-added: yes
|
|
span=4c260ac5c25a
self-added: yes
|
|
span=60eee5df9fed
self-added: yes
|
|
span=52a4927c3548
self-added: yes
|
…lag, default off)
Prototype behind dashboard.replay_from_acp (default false). When on, the
kiro-shared runtime keeps the session/update frames kiro-cli replays during
session/load instead of counting them as drops, hands them to the session
handle, and the slot-detail handler rebuilds the transcript from them through
the same parse_session_update the live path uses, overlaying only the JSONL
rows the agent never saw (notices, approvals, steer/inject provenance,
compaction banners). JSONL is still written either way.
Measured against kiro-cli 2.21.0 (research/acp-replay): every engine speaks
ACP protocolVersion 1 and has no session/resume; replay is authoritative for
assistant text, thinking and tool id/rawInput/rawOutput/status, while v1/v2
collapse tool titles to the raw tool name and stamp no messageId, and a
session/load for a sid another process holds is refused -- so the frames are
captured at the gateway's own resume, never fetched on demand.
- acp/runtime.py: capture_replay + bounded per-load capture bucket
- acp/_dispatch.py: record_metrics opt-out so replayed calls do not emit
near-zero duration samples
- dashboard/chat_replay.py: merge + sidecar overlay (tool_call_id / prompt
containment anchors)
- dashboard: transcript_source + replay_report on GET /api/chat/slots/{slot},
banner in ChatPage, Settings toggle, config flag + baseline
- tests written for the capture path and the merge (CI runs them)
|
span=52a4927c3548
self-added: yes
|
Summary
Design rationale:
docs/request-for-change/rfc-acp-replay-as-transcript-source.md— why the replay should own what the agent saw and the JSONL should shrink to a sidecar, with phasing P0–P3.Prototype behind
dashboard.replay_from_acp(default off): render a resumed session's transcript from kiro-cli's ownsession/loadreplay, with Kiro Crew's JSONL supplying only the rows the agent never saw. The JSONL keeps being written either way — this changes what a resumed session reads, never what it persists.Today
AcpRuntime.load_sessionissuessession/loadand the reader loop counts and drops every replayedsession/updateframe ("counted, not logged"); the dashboard then renders the JSONL it wrote itself. So the transcript is dual-written and the agent's own record is thrown away. With the flag on:AcpRuntime(capture_replay=True)arms a per-sid bucket before the load request goes out; the reader appends that sid'ssession/updateframes instead of dropping them (bounded by_REPLAY_CAPTURE_MAX_FRAMES = 20_000; overflow takes the ordinary counted drop). The bucket is popped on every exit of the load, so a failed/timed-out load never leaves an armed bucket for an id a later session reuses. Frames land onAcpSessionHandle.replay_updates.dashboard/chat_replay.pyfolds those frames into rows through the sameparse_session_updatethe live path uses (record_metrics=Falseso replayed calls do not emit near-zerokirocrew.tool.call.durationsamples), then overlays JSONL rows: clean user text /mid/sendId/ steer / nudge flags from the matching JSONL prompt row (matched by containment inside the replayed assembled prompt, dash-folded), humanized tool title / purpose / kind from the JSONL tool row with the sametool_call_id, and every non-derivable row (notice, error, permission, inject, compaction, steer-user) re-inserted at its turn, anchored on the tool it followed.GET /api/chat/slots/{slot}returnstranscript_source(jsonl|acp_replay) andreplay_report(rows per source); every row carriesmeta.source.ChatPageshows a banner when the transcript came from replay. Settings → Chat gets the toggle;dashboard/configGET/PUT carry it.Flag off (the default) is a no-op:
AcpProvider.replay_updatesanswersNone, the handler keeps the JSONL path, the runtime keeps its counted-drop behaviour.What the probe measured (kiro-cli 2.21.0, engines v1 / v2 / v3)
Full report + scripts live in the operator workspace (
research/acp-replay/acp-replay-fidelity.md); the load-bearing findings are also recorded indocs/system-specs/modules/acp-client.md:initialize.protocolVersionanswered111(rejects our dated string with -32602; we already send the integer there)session/resume+replayFromtoolCallId/rawInput/rawOutput/ finalstatustitleread,shell)locations,contentpreview,_meta.kiro.toolNamemessageId/timestamp/replay:trueon framesuser_message_chunksession/loadon a sid another process holdsSession is active in another process, 0 frames)Real Kiro Crew session (690 JSONL rows, 395 tool calls, 100 nudge cycles, one auto-compaction) replayed in a fresh process: 395/395 tool rows matched by
tool_call_id, 176/177 assistant rows matched, 100/100 nudges and 10/11 user rows found wrapped inside the replayed prompt; only[Tool blocked]injects, transient-retry errors and the compaction banner had no replay counterpart. No truncation at the compaction point.The refused-resident-sid result is why this design captures at the gateway's own resume rather than asking kiro-cli to replay on tab open.
Screenshots — same session, same pod, flag on vs off
Differences between the two renders, in red:
Read the specified file to quote its first line. · replay-demo-notes.txt) built from the replayedrawInput; OFF shows the JSONL's collapsed "Worked through 1 step".--approval reads, so the shell turn raised a permission request; resolved permission rows are not surfaced by_prepare_messagesafter a restart in either mode, so there is no visible difference to show. Cron-injection cards were not produced in this demo session (no cron ran into it) — the overlay path for them is the generic sidecar branch, covered bytest_sidecar_rows_anchor_after_their_tool.dashboard.replay_from_acp = ONdashboard.replay_from_acp = OFF(default)Settings → Chat → Sessions, the new toggle (outlined in red):
Review round 1 — what changed
limit/beforeslicing (_merge_replay_corpus), sototal/has_more/next_beforeare computed in the merged index space and every page is cut from the same list; the unbounded path merges only when no rotated-archive head is advertised. Merged rows are cached per slot on (frame count, corpus length, last row) so the detail poll does not re-walk the replay each time (design-review suggestion).replay_updatesis declared onLLMProviderwith aNonedefault and read directly — nogetattrprobe.chatrow updated.kiro_crew.agent_sdk.drivers.acp.fold_replay_updates(function-local ACP imports, plain-data result);dashboard/chat_replay.pyno longer imports the ACP layer.context.USER_REQUEST_HEADERconstant imported by the reader (dash-folded compare) instead of a respelled literal.{{productName}}interpolation); the Settings toggle is screenshotted above.replay_coveredandunmatched_jsonl_turns(no consumers);transcript_sourceis emitted only on the replay path, so default installs gain no new field.bg-cardinstead of the undeclaredbg-surface).Review round 2 — what changed
read_rotated_messages_chained, fail-closed to JSONL), so the cursor an initial response hands out is always applied to the same corpus shape._REPLAY_CAPTURE_MAX_BYTES(64 MiB, serialized) alongside the frame cap; on overflow the whole capture is discarded (not truncated) and the handle gets an empty list, so the consumer renders its own transcript. Test added.a/a longer question about acase and the guidance-suffix case.chat_handlers.py; zh-CN copy reworded to keep ≤ 2的per clause.Review round 3 — what changed
/plainrow reaches the agent as its expanded macro, whose first line is the[QUICK PROMPT <token>]header both derive from.quick_prompts.quick_prompt_header()(new public helper on the writer's own module) lets the matcher recognise the row by that header instead of leaving it unmatched and rendering the turn twice. Test added with the realexpand_quick_prompt("/plain")output.Review round 4 — what changed
meta.resolved,meta.done, output length, turn stats, file changes, content length,mid,ts), not just corpus length and last row, so a permission resolved or a tool completed after the last merge misses the cache.switchSlot/refreshSlotreducers deleteslotTranscriptSource[slot]when a detail response carries notranscript_source, so the banner cannot outlive the replay (flag off, provider gone, rotated archive).replay_from_acp400 now carriescode: invalid_replay_from_acp, returninghandlers/files.pyto its baseline count. The other Windows failure (test_dashboard_status_snapshot— xdist worker crash) is unrelated to this diff.Review round 5 — what changed
switchSlot/refreshSlotreducers now guardstate.slotTranscriptSource(??=on set, existence check on delete) — test fixtures and persisted states predating the field buildChatStatewithout it, anddelete undefined[…]threw inChatPageCoverage.test.tsx.LLMProvider.discard_replay()(no-op default;AcpProvider/AcpSessionProviderdrop the handle's frames) is called by rewind, regenerate, edit-and-resend and switch-variant viachat_replay.discard_replay_for_slot, which also drops the merged-rows cache. Independently, the merge itself treats a turn whose JSONL assistant text matches none of the replay's as REWRITTEN and renders that turn from the JSONL, so the old answer cannot come back even if an invalidation is missed. The cache moved intochat_replay.py(merge_replay_transcript_cached,corpus_witness) so the rewrite endpoints can reach it without importingchat_handlers. Tests added for the rewritten turn, the in-place-mutation cache miss, and the invalidation.Review round 6 — what changed
discard_replay_for_slotmoved from the top of each rewrite handler to immediately before the transcript mutation (del slot.messages[…]in regenerate / edit-and-resend, the variant content swap, and rewind's_commit_live_state), so a request refused by an authorization or validation gate discards nothing.Review round 7 — what changed (rebase onto current main)
chat_handlers.py): the paginated path no longer re-decides replay-vs-JSONL per request from the rotation probe. The FIRST page of a chain (nobefore) decides,chat_replay.note_cursor_spacerecords it per slot, and every follow-up page (beforeset) reads it back viacursor_space, so a size rotation landing between two "load earlier" requests cannot flip the corpus under a cursor the client already holds. The paginated read is the full chained corpus, so merging stays valid across a rotation. The unbounded path records its space too (its rotated-archive cursor is JSONL-space). A rewrite forgets the record along with the merge cache; an unknown slot (gateway restart mid-scroll) decides afresh._replay_capture_frame_cappedtracks the sids discarded for the FRAME cap separately from byte-ceiling discards; every later frame for such a sid still increments_replay_capture_overflow, so the discard log reports how far past the cap the replay ran (byte discards keep overflow at 0, astest_byte_ceiling_discards_whole_capturepins).test_capture_cap_discards_whole_capture(overflow == 2) now matches the code.len(json.dumps(...).encode("utf-8")), not a character count, so CJK-heavy frames are measured against the 64 MiB memory bound correctly.test_compaction_notice_stays_as_sidecar_after_its_turn): JSONL-only rows are anchored on the last replayed row they followed — tool call OR matched assistant text — instead of tool calls alone, so a compaction banner that followed the answer no longer renders before it.test_sidecar_rows_anchor_after_their_toolupdated accordingly (the trailingerrorrow now follows the matched answer, matching the JSONL order).agent_sdk/drivers/acp.pyanddashboard/chat_replay.pyadded toNON_EGRESS_REDACTION_MODULESwith the reason — both scrub as the rows are BUILT; the registered dashboard sinks serve them._commit_live_state(main moved the truncation onto a prospective copy committed after the durable boundaries); locale catalogs re-derived by key-level replay onto main's bytes (+4 keys each, no reordering).Review round 8 — what changed
/clearkeeps the replay (GPT F1,chat_runner.py): theEVENT_CLEAR_STATUShandler now callsdiscard_replay_for_slotbeforeslot.messages.clear(), so the live provider's replay frames, the merge cache and the cursor-space record go with the wiped transcript — the next detail fetch renders the (empty) JSONL instead of rebuilding the cleared conversation from retained frames. Same discipline rewind / regenerate / edit-and-resend already follow.test_snapshot.py::TestNotificationCopyWhenNoLiveFileExists, two ordering tests) is not this PR's: the diff touches no snapshot code, main's own ci.yml failed the same tests at002081169and passed them at this PR's base30946933c.Review round 9 — what changed
_CURSOR_SPACEmap is gone. Every page that hands outnext_beforewhile a replay is on offer also returnscursor_space("acp_replay"|"jsonl"), and the client echoes it back ascorpus=with the nextbefore(api.chatSlotDetail,loadOlderMessages, threaded throughsetPagingCursor/ the switch-origin cursor). The server keeps no cross-request state: two clients paginating one slot each carry their own space. A follow-up page that names no corpus (a caller predating the field, or the resume endpoint's JSONL cursor) fails closed to the JSONL corpus; the field is absent from the response whenever no replay is on offer, so default installs gain nothing new.top-level-imports(GPT advisory,agent_sdk/drivers/acp.py): kept function-local on purpose and now says so at the import — the module docstring already documents that everykiro_crew.acpimport here is call-time because the module sits on the dashboard boot path and the ACP package pulls in the client and runtime.Review round 10 — what changed
chat_handlers.py): a follow-up page that echoescorpus=acp_replaywhile the slot no longer offers a replay (regenerate / rewind / edit //clear/ session reset discarded it between two pages) now answers 409cursor_staleinstead of slicing the differently-indexed JSONL with the replay-space offset.loadOlderMessagesrecognises the code (isStaleCursorRejection) and re-reads the first page throughrefreshSlot— a fresh cursor in the current corpus — rather than showing the red retry bar.loadOlderMessagespassescorpustoapi.chatSlotDetailonly when the backend named one, so default installs send exactly the request they always did (the round-9 shape made two existing vitest suites see a fifthundefinedargument).allchunk 10.25 MB vs 10.24 MB, +5.3 KB) is main-side drift of the eager i18n catalogs: pristine main measures 10450 KB against the 10490 KB ceiling, and the re-measure lands in fix(ci): re-measure the drifted all-chunk bundle ceiling #8935. Not folded in here; this PR rebases once that merges.fetch failedon the runner's tool download step, not a test.Review round 11 — what changed (rebase onto main after #8935)
chat_replayany more.chat_handlers._merge_replay_corpusimports the merger only after replay frames are confirmed present, and the five rewrite call sites (rewind, regenerate, edit-and-resend's commit, variant switch,/clear) go throughchat_utils.discard_slot_replay, which checksprovider.replay_updatesfirst and importschat_replay.discard_replay_for_slotonly when a replay is actually on offer — AUTOSDEno-new-work-on-gateway-boot-pathrule 5, gate the import, not just the handler. A default (flag-off) install never loads the module: not at boot, not on a rewrite.chat_replay._match_prompt): each pass now collects every JSONL prompt group that matches the replayed prompt text; when there is more than one, the group whose body shares an identity with the replayed turn wins — atool_call_idthe replay carries, or an assistant text the replay has (_turn_identity_score). A replay that omits the first of two identical turns therefore enriches the second turn instead of handing its thinking and tools to the first. Ties (nothing in common) keep the earliest, the previous behaviour. Pinned bytest_duplicate_prompts_attach_replay_to_the_turn_that_shares_identityandtest_duplicate_prompts_without_identity_keep_the_earliest.all-chunk ceiling; the only conflict wasdocs/request-for-change/README.md(main rewrote the audit preamble; this PR's RFC row appended to main's table). The round-9deadKeysred (30 vs baseline 29) was the old base — on the rebased tree the dead set is identical to main's (29).test_approval_threading(subagent approval threading),test_snapshot(notification-copy ordering, also red on main's own runs),test_session_poolwedged-shutdown timeout on Windows — none touch replay code; Backend Lint was cancelled by a superseding run, not failed.Review round 12 — what changed
line citations in prose): the RFC cited eightfile.py:NNNlocations; docs-lint wants symbol names, which survive the refactor that moves a line. Replaced with the symbols (AcpRuntime.load_session,AcpRuntime._note_dropped_frame,_capture_replay_frame/_take_replay_capture,merge_replay_transcript, the fivediscard_slot_replaycall sites by function). The dangling branch-head SHA in its front matter (audited-at) now names the main base the branch is measured against.Review round 13 — what changed
chatSlice.ts): athinkingrow taggedmeta.source === 'acp_replay'comes back on every detail fetch, so it is no longer treated as the client-only live block.isReplayBackedThinkingmakesisDurableRowcount it (paging-cursor arithmetic and coverage),mergePreservedThinkingskips preserving it (the page carries it again), and the three kept-head filters keep it as a server row. Live-broadcast reasoning (nosource) keeps the existing preserve-and-re-seat path.corpus_witness): the witness is now a blake2b digest of every row (role, ts, content, fullmeta), not lengths and flags, so a same-length in-place refinement ofpurpose/input/ any sidecar field misses the cache.test_cached_merge_misses_on_in_place_mutationextended with a same-length purpose change._match_prompt): when the best identity scores tie, the turn is reported_AMBIGUOUSand the merge drops its replay rows, letting the JSONL — which records both turns — stand in order; it no longer picks the earliest.test_duplicate_prompts_with_tied_identity_fall_back_to_jsonlreplaces the earliest-wins test.Review round 14 — what changed
_is_prompt_row): the queue drain persists a cron notification, a recovery re-send, a user-message replay and the synthesis prompt asinjectrows (withmeta.injectKindnaming the dispatch) and a sub-agent completion as asubagentrow — never asuser. Those rows now open their own JSONL group, so the replayed turn lands on them instead of leaving the prompt in the previous group and rendering it twice with its answer under the wrong prompt. Classification is by persisted provenance (_TURN_STARTING_INJECT_KINDS=cron/recovery/user_replay/synthesis, and rolesubagent); aninjectwithout one of those kinds — a policy notice, a reconcile note — is still delivered inside a turn and stays sidecar. Pinned bytest_cron_and_subagent_dispatches_are_matched_as_their_turns_promptandtest_in_turn_inject_without_dispatch_kind_stays_sidecar.test_snapshot.pynotification-copy ordering again (also failing on main's own runs); not owned here.Review round 15 — rebase onto current main
af7f3e7fe(184 commits). Two conflicts, both unions:acp/_dispatch.pygained main'sdiff_path_cacheparameter beside this PR'srecord_metrics(both threaded throughparse_session_update→_tool_call_event), andcontext.py's request-header line keeps main's reply-format scrubbing block with this PR'sUSER_REQUEST_HEADERconstant in place of the literal. Locale catalogs merged cleanly (+4 keys per catalog, duplicate-key scan clean); the dead-key set equals main's (28). No code change beyond the merge.Review round 16 — what changed
acp/runtime.py): the per-session 64 MiB ceiling bounded one resume, not N concurrent ones. A module-level_ReplayRetentionLedger(_REPLAY_RETAINED_MAX_BYTES= 256 MiB, shared by everyAcpRuntime, lock-guarded) now reserves each accepted frame's bytes per session; a capture that would push the process past the budget is discarded whole — its consumer keeps the JSONL — rather than evicting a session already rendering from its frames. Bytes stay reserved while the handle holds the frames and are released on a failed load, a capture discard (either ceiling or the frame cap),AcpSessionHandle.discard_replay(now the seamdiscard_replayon the provider goes through, so a rewound/cleared session stops counting), andunregister_session. Pinned bytest_process_wide_budget_discards_a_new_capture_and_frees_on_release; the discard log line reports the process-wide total.capture_replaystill reaches every provider the factory builds, but a capture only arms onsession/load— a resumed dashboard session;session/newsessions (sub-agents, crons, background prompts) replay nothing and retain nothing. Stated at the factory call site and indocs/system-specs/modules/acp-client.md, which now lists all three bounds and the release paths.Review round 17 — rebase onto current main
575a8390e(12 commits); the only conflict was the RFC index (docs/request-for-change/README.md, both rows kept). No code change. Round-16 reds not owned here: Windows shard 4test_work_ledger(fails on main's own runef38f4cbe), Windows shard 2test_external_registry/test_file_change_snapshots(Windows-only; the same Linux shard passed, and this PR's only_dispatch.pydelta is therecord_metricsflag with an unchanged default); the Lint check-run reported red while its job had completed green.Review round 18 — what changed
chat_replay._MERGE_CACHE): entries now record their serialized size; the cache holds at most_MERGE_CACHE_MAX_BYTES(64 MiB) in aggregate, evicting oldest-first, and a single merge past_MERGE_CACHE_ENTRY_MAX_BYTES(16 MiB) is served but never cached — re-merging it later is cheaper than pinning that memory for one slot. A slot's stale entry is dropped before its replacement is sized. Pinned bytest_merge_cache_is_bounded_by_bytes_and_skips_oversized_entries.runtime.py): the_REPLAY_CAPTURE_MAX_FRAMESnote now says the whole capture is discarded and the consumer renders its JSONL, which is what the branch does.check_comment_history.py, new on main): three comments this PR wrote said "no longer" — reworded to present tense. The other two failures the gate reports on this PR —context.py12 vs baseline 11 andchat_runner.py109 vs 103 — are main's own drift (the gate scans only touched files, so main's pushes never saw it; measured on pristinefd6684b40with the gate's own scanner). Fixed in chore(comments): drop the history narration main outgrew its baseline on #9364, not here; this PR rebases once that merges.test_security_conductor_skill_contract(Linux + Windows) fails on main's own run2f9ed9724; Windows shard 4test_work_ledgeris the known flake.Review round 19 — what changed
chatSlice.ts): a refresh whose page was cut from a different corpus than the view's cursor (cursor_spaceon both sides, absent = JSONL — the provider came up between two fetches, or went away) now refetches unbounded instead of keeping a head and subtracting its row count from the new cursor;switchSlotapplies the same rule to its cached transcript (corpus read from the cachedtranscriptSourcebefore it is overwritten) and keeps no head across a corpus change. Both skip the check when the page reaches the start of history, since that page replaces the view whole and no arithmetic crosses anything — so a short session with the flag on pays nothing extra.Review round 20 — what changed
AcpRuntime._mark_dead): a dead runtime cannot serve a replay, and a session never destroyed after the death would have kept its bytes reserved in the process-wide ledger for the gateway's lifetime, starving later resumes._mark_deadnow releases every sid the runtime knows — registered sessions and captures still in flight — and clears the capture maps. Pinned bytest_runtime_death_releases_every_session_reservation.loadOlderMessages): therefreshSlotrefetch is awaited; if it rejects, the load rejects too (rejectWithValue({ slot })), so the red retry bar appears instead of "load earlier" becoming a dead click.1162ca6ea, pushed by the co-owner) also carries the seven present-tense rewrites inchat_runner.py/context.pyand the baseline entry from chore(comments): drop the history narration main outgrew its baseline on #9364, so Backend Lint clears here without waiting. They are byte-identical to chore(comments): drop the history narration main outgrew its baseline on #9364; whichever merges first, the other collapses to no diff on rebase.test_security_conductor_skill_contract— fixed on main by fix(test): security-conductor scripts guard checks for stubs, not absence #9362, which the merge ref of this push picks up.Review round 21 — what changed
_mark_deadon a half-constructed runtime (shard 1, own):test_acp_runtime_killbuilds anAcpRuntimewith__new__and only the attributeskill()reads, so the round-20 release loop failed on_replay_capturebeing absent. The death path now reads the capture maps withgetattrand clears whichever exist — death can genuinely reach a runtime whose spawn failed before construction finished.Review round 22 -- what changed
AcpRuntime._mark_dead): frames still in the runtime's capture buckets die with the process, so their ledger reservation is returned; frames already handed to a session handle keep serving the dashboard after the death, so their bytes stay counted. They return when the handle drops them:discard_replay(transcript rewrite),unregister_session(subagent destroy), or -- new --AcpSessionProvider.shutdown(), which callshandle.discard_replay()beforeruntime.kill(), closing the owned-teardown leak without under-counting memory a handle still holds. Pinned bytest_runtime_death_releases_in_flight_captures_onlyandtest_shutdown_discards_the_handles_replay_before_killing.chat_replay._MERGE_CACHE_LOCK): every read, eviction, invalidation and write of_MERGE_CACHEruns under onethreading.Lock; the merge itself stays unlocked so slots do not serialize on one CPU. The cache is reached off-loop (asyncio.to_thread), so two slots' detail fetches could previously interleave an unsynchronizedpop(next(iter(...))).Frontend Tests (2)DrivePage.test.tsxshare-dialog focus-return (aws-control, untouched by this PR; passes on main's own run at the same base) -- rerun.Review round 23 — rebase onto main after #9364
c02cdd67c. chore(comments): drop the history narration main outgrew its baseline on #9364 is merged, so the comment-history rewrites and baseline entry this branch carried collapse to no diff;context.py/chat_runner.pyare back to this PR's own lines only (theUSER_REQUEST_HEADERconstant and the/cleardiscard). Conflicts:comment-history-baseline.json(took main's), andchat_rewind.py, where main moved the rewind's live rewrite into a_commit_live_statethat carries rows that arrived during the boundaries — the replay discard now sits inside that commit, beforeslot.messagesis reassigned, the same placement edit-and-resend already has.Known limits of the prototype
acp_replayhappens on the next detail fetch.tool_call_id, prompt containment), not by a message identity; the v3 engine stamps_meta.kiro.messageId, which would replace the containment match.meta.sourcebut the per-row UI badge is not in this PR; only the banner is.Tests
test/test_acp_runtime_replay_capture.py— capture helpers, cap/overflow, reader-loop routing (armed sid kept, others dropped, capture-off unchanged),load_sessionattaches frames and disarms on failure.test/test_chat_replay_merge.py— prompt stripping, turn folding, tool enrichment, sidecar anchoring, dash-folded nudge matching, JSONL turns missing from the replay, steer/compaction handling, redaction.Static gates run locally:
black,flake8,mypy --python-version 3.12on the touched Python;tsc -b,eslint,npm run i18n:check,gen:settings,generate_config_baseline.py. Test execution is left to CI.