Skip to content

fix(acp): skip unreplayable transcript entries instead of failing the load - #4040

Merged
probepark merged 3 commits into
Yeachan-Heo:devfrom
probepark:pr/acp-transcript-replay
Aug 9, 2026
Merged

fix(acp): skip unreplayable transcript entries instead of failing the load#4040
probepark merged 3 commits into
Yeachan-Heo:devfrom
probepark:pr/acp-transcript-replay

Conversation

@probepark

Copy link
Copy Markdown
Collaborator

Scoped replacement for the transcript-replay portion of the closed #4021. One commit, 2 files.

Fixes #4019.

The problem

One transcript entry without a production body makes session/load fail for the whole session, permanently. Since that is the documented recovery path after a transport drop, a session whose transport dropped can never be recovered — it ends closed with its work unreachable.

Captured while running a batch of agents:

  1. A session lost its transport (SDK WebSocket reconnect attempts exhausted).
  2. Sending a new prompt returned Unknown session, not found: ad9903df-… (not_found).
  3. The client then tried the documented recovery — a refresh, which drives session/load:
    Internal error: ACP cannot replay a transcript entry without its production body.
    requestType=refresh_agent_request  code=agent_refresh_failed
    
  4. Reproduced after a full SDK broker restart and after switching to a freshly compiled binary. The agent stayed closed.

Step 2 alone is fine — gjc advertises loadSession: true, so a client is entitled to recover via session/load. Step 3 is what removes that guarantee.

Mechanism

transcriptReplayContent threw transcript_body_unavailable when record.body was not a string, and #replaySession calls it for every item on every transcript page. A single bad entry aborted the entire replay, so the failure was total and permanent rather than partial — and deterministic, so every retry died identically.

The fix

Replay decides per entry. transcriptReplayContent returns a discriminated { replayable: true, content } | { replayable: false, reason } instead of throwing, and the replay walk skips unreplayable entries, reporting a count and a stable machine-readable reason through the same session_info_update _meta channel already used for unavailable historical images:

images: { available: false, reason: "historical_transcript_images_unavailable" }

No fabricated body — that would replay a message that never existed. A session whose entries are all unreplayable still loads, reporting zero replayed messages.

Verification

Load-bearing proof — restoring packages/coding-agent/src from origin/dev and re-running:

with fix:     5 pass / 0 fail
without fix:  1 pass / 4 fail
packages/coding-agent/test/acp (14 files)   160 pass / 0 fail
bun --cwd=packages/coding-agent run check   exit 0

Coverage: one bad entry among good ones — the good ones replay, the bad one is skipped and reported with count and reason; every entry unreplayable — load still succeeds with zero replayed messages; healthy transcripts replay with an unchanged update sequence.

Relationship to #4021

#4021 bundled twelve unrelated defects into 53 files and was closed with the instruction to open fresh, scoped PRs. This is one of those, alongside #4031, #4033, #4035, #4036, #4037, #4038 and #4039.

Closes #4019

… load

A session whose transport dropped could never be recovered: `session/prompt`
answered `Unknown session, not found`, and the documented recovery through
`session/load` then died on `ACP cannot replay a transcript entry without its
production body`. One malformed row revoked `loadSession` for the entire
session, and the agent ended `closed` with its work unreachable — reproduced
across a broker restart and a fresh binary.

Replay now decides per entry: an entry without its production body is skipped
and reported through the same `_meta` boundary channel that already reports
unavailable historical images, so a session with zero replayable rows still
loads.

Lore-id: c4e7b285
Constraint: never fabricate an empty body -- that replays a message that never existed
Constraint: gjc advertises loadSession: true, so load must not be revocable by one bad row
Rejected: keep throwing and let the client retry | the failure is deterministic, every retry dies identically
Rejected: substitute a placeholder body | silently invents transcript content the user never wrote
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: one bad entry among good ones -- good ones replay, the skip is reported with count and reason
Tested: every entry unreplayable -- load still succeeds with zero replayed messages
Tested: healthy transcripts replay with an unchanged update sequence
Not-tested: recovery against a live host that dropped mid-turn
@probepark

Copy link
Copy Markdown
Collaborator Author

Self-review: REQUEST_CHANGES on my own PR. A red-team pass found that skipping is the wrong answer for the case that actually produces body-less entries, and that it creates two new broken states. I verified both in source.

Blocker 1: the entries this PR skips are usually RECOVERABLE, not corrupt

packages/coding-agent/src/sdk/host/query/handlers.ts:302 emits, for any transcript entry over TARGET_PAGE_BYTES:

[{ id: item?.itemId, error: { code: "item_too_large" }, continuations }]

That row has no body — and continuations tells you exactly how to fetch it. So the common producer of a body-less entry is not a malformed transcript; it is a large message that must be read through its continuation. My PR treats it as unreplayable and drops it, which trades a loud total failure for the silent loss of the largest message in the session. #4019 and this PR both missed that path.

Blocker 2: skipping breaks tool-call pairing

#replaySession builds replayTools from toolCall blocks (acp-agent.ts:2252) and a later toolResult resolves its name through that map:

const replayTool = replayTools.get(message.toolCallId);
const toolName = typeof message.toolName === "string" ? message.toolName : replayTool?.name;
if (!toolName) continue;                       // :2273

Skip the entry that owned the toolCall and two reachable broken states follow:

  • an orphan tool_call_update for an id the client never saw start;
  • a tool call left at status: "pending" forever, because its result was dropped at :2273.

The replayed session then renders as permanently mid-flight.

What the fix has to be

Skipping must be the last resort, not the first. Replay should:

  1. follow continuations for item_too_large and reassemble the body — that is what the field is for;
  2. only report-and-skip an entry that is genuinely unrecoverable;
  3. when it does skip, keep tool-call pairing coherent — either drop the paired result too, or synthesise a terminal state so nothing is left pending.

The core premise of the PR still stands: one bad entry must not fail the whole session/load. But "skip" alone is not the right recovery, and I shipped it without checking what actually produces these rows.

Not merging until continuation recovery is in and both broken states have regressions.

…tions

The replay treated any entry without a production body as unreplayable and
skipped it — but the common producer of a body-less entry is the host's own
item_too_large envelope, which ships continuations describing exactly how to
fetch the content. Skipping traded a loud total failure for the silent loss of
the largest message in the session, and a skipped entry that owned a toolCall
left an orphan update and a call stuck at pending forever.

Replay now follows continuations and reassembles the body through the existing
query path; only a genuinely unrecoverable entry (no body, no usable
continuation) is reported-and-skipped, and a skip keeps tool pairing coherent
by dropping the paired result instead of leaving it pending.

Lore-id: 7f1e9c53
Constraint: one bad entry still never fails the whole session/load
Constraint: a session whose entries are all unrecoverable still loads with zero replayed messages
Rejected: fabricating an empty body | replays a message that never existed
Rejected: leaving pairing to the client | the client cannot know a start it never saw was skipped
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: an item_too_large entry with continuations is recovered and replayed
Tested: a genuinely unrecoverable entry is skipped with count and stable reason
Tested: a skipped toolCall owner leaves no orphan update and nothing pending
Tested: healthy transcripts replay with an unchanged update sequence
Not-tested: multi-page continuation chains against a live host
@probepark

Copy link
Copy Markdown
Collaborator Author

Both blockers fixed in 5a288988d: replay follows continuations and reassembles item_too_large bodies through the existing query path (no fabricated content), only genuinely unrecoverable entries are reported-and-skipped, and a skip keeps tool pairing coherent — no orphan updates, nothing left pending. 170 pass / 0 fail across test/acp; 4 pass/5 fail without src.

@probepark
probepark requested a review from Yeachan-Heo August 8, 2026 19:09
Continuation recovery discarded a partial result: if `role`, `toolCallId` or
`toolName` failed to recover, the entry was skipped after its tool call had
already been published, leaving a call stuck at pending forever and an oversized
successful result reported as failed whenever its `isError` continuation was
absent.

Recovery is now all-or-nothing per entry, and a skip after publication
synthesises the terminal state instead of abandoning it, so a replayed session
never renders permanently mid-flight.

Lore-id: 8f3c1d64
Constraint: one unrecoverable entry still never fails the whole session/load
Constraint: absence of an isError continuation is not evidence of failure
Rejected: publishing the partial entry | a tool call with no name cannot be rendered
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: a partial continuation failure leaves nothing pending
Tested: an oversized successful result is not reported as failed
Tested: healthy transcripts replay unchanged
Not-tested: multi-page continuation chains against a live host
@probepark

Copy link
Copy Markdown
Collaborator Author

Local codex-pro review gate: APPROVE, no blockers, after c98e1e48d.

Continuation recovery was discarding partial results: if role, toolCallId or toolName failed to recover, the entry was skipped after its tool call had already been published — leaving a call pending forever — and an oversized successful result was reported as failed whenever its isError continuation was absent. Recovery is now all-or-nothing per entry, and a skip after publication synthesises the terminal state.

200 pass / 0 fail across test/acp + query pagination; 14 pass / 4 fail with src stashed; check exit 0.

@probepark
probepark merged commit 8f6213a into Yeachan-Heo:dev Aug 9, 2026
26 checks passed
probepark added a commit to probepark/gajae-code that referenced this pull request Aug 9, 2026
…ing it pending

Yeachan-Heo#4040 made transcript replay skip entries it could not reconstruct, so a broken
row stopped failing the whole session load. One skip was in the wrong place: a
`toolResult` that resolved no tool name hit `continue`, which also skipped the
`tool_execution_end` notification and the `replayTools` bookkeeping. The start had
already been published, so the client kept a tool call at `pending` for the life of
the session -- the opposite of the all-or-nothing recovery the change claimed.

A result row that lost its own name now falls back to the name its start carried,
which is a result the client can still place. A call nothing can name is closed as
failed with an honest reason instead of being abandoned mid-flight.

Found by the post-merge audit in Yeachan-Heo#4063.

Constraint: a published tool call MUST reach a terminal status or the load MUST fail
Rejected: failing the whole continuation | reintroduces the Yeachan-Heo#4019 symptom
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: result row missing a name, call nothing can name, call never resolved
Not-tested: a start row that is itself unparseable
Yeachan-Heo pushed a commit that referenced this pull request Aug 9, 2026
…4082)

* fix(acp): give a replayed tool call a terminal status instead of leaving it pending

#4040 made transcript replay skip entries it could not reconstruct, so a broken
row stopped failing the whole session load. One skip was in the wrong place: a
`toolResult` that resolved no tool name hit `continue`, which also skipped the
`tool_execution_end` notification and the `replayTools` bookkeeping. The start had
already been published, so the client kept a tool call at `pending` for the life of
the session -- the opposite of the all-or-nothing recovery the change claimed.

A result row that lost its own name now falls back to the name its start carried,
which is a result the client can still place. A call nothing can name is closed as
failed with an honest reason instead of being abandoned mid-flight.

Found by the post-merge audit in #4063.

Constraint: a published tool call MUST reach a terminal status or the load MUST fail
Rejected: failing the whole continuation | reintroduces the #4019 symptom
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: result row missing a name, call nothing can name, call never resolved
Not-tested: a start row that is itself unparseable

* fix(acp): close every published tool call on any exit from transcript replay

Round one gave a nameless tool result a terminal status instead of skipping it,
which fixed the `continue` path and left the throw path open: a later
`transcript.list` page that failed exited the replay before any cleanup, so every
call already published as `pending` stayed pending for the life of the session.

Guarding that one site would have been the same mistake a third time. The replay
body now runs inside a single boundary, and whatever leaves it -- normal return,
early exit, or a page that throws -- closes every start still open.

A call abandoned because the replay itself stopped reads differently from one
abandoned because nothing could name it, so the two carry different reasons; the
client can tell "this call failed" from "the replay could not finish".

Constraint: a published tool call MUST reach a terminal status on every exit path
Rejected: guarding the throw site | a future early exit reopens the same hole
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: second transcript page throws, unnameable call, call the transcript never resolved
Not-tested: a failure inside the cleanup loop itself

* fix(acp): finish the replay cleanup and report what it could not close

Round two put the replay body inside one boundary so every exit closes still-open
tool calls. The cleanup itself could still fail: `#publishSessionUpdate` fails the
session on error, so the first failing publication tore down the record and every
later close became a silent no-op. The boundary ran and accomplished nothing --
the calls queued behind that first failure stayed `pending` for the life of the
session, which is the defect this branch exists to remove.

Every close is now attempted, and the ones that could not be published are
reported rather than dropped. A cleanup that cannot report its own failure is not
a cleanup; that silence is what kept the original defect invisible for a whole
session.

Constraint: one failed close MUST NOT prevent the remaining ones
Constraint: a close that could not happen MUST surface, never be swallowed
Rejected: wrapping the loop in another try | the third variation of guarding one site
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: cleanup publication fails partway; remaining calls still close and the failures are reported
Not-tested: a session torn down concurrently with the cleanup

* fix(acp): stop the replay cleanup from destroying what the rest of it needs

Round three made the cleanup report its failures, and a review on a different
model showed the report was worth nothing. Two reasons, both structural.

The cleanup published through `#publishSessionUpdate`, which calls `#failSession`
and deletes the session record, so the first failing close tore down the state
every later close depended on -- the boundary ran and accomplished nothing. And
`replayTools.delete(...)` ran before the close was attempted, so a call whose
close was refused had already left the map and never appeared in the report. The
cleanup could fail, take the session with it, and name none of the calls it
abandoned.

Entries now leave the map only after their close succeeds, and the cleanup does
not publish through the path that fails the session. What still cannot be closed
is named in a report that production reads.

Four rounds in one invariant is what a single model's blind spot looks like; this
round came from reviewing on a different one.

Constraint: a published tool call reaches a terminal status on every exit path
Constraint: cleanup MUST NOT depend on state its own failure destroys
Rejected: remove-on-attempt | drops exactly the calls the report exists to name
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: page throws mid-replay, cleanup publication fails partway, unnameable call, call the transcript never resolved
Not-tested: a session torn down concurrently with the cleanup

* fix(acp): end the terminal obligation where the session ends

Five rounds patched five routes to the same invariant, so this round settled the
question the patching kept deferring: when a concurrent `session/close`, a failed
session, or a connection teardown removes the session mid-replay, is a pending tool
call still a defect?

It is not. The removal takes the client's view of those calls with it -- there is
nobody left to observe a `pending` one, and a frame carrying a closed session id is
one the client asked to stop receiving. The obligation ends where the session ends,
and the next `session/load` replays the same transcript rows from scratch.

That is now stated at the exit instead of left as a silent return that reads like a
bug. The session is read once at the cleanup boundary rather than at each exit,
because `#closeReplayToolCall` deliberately bypasses `#publishSessionUpdate` -- so
its own failures cannot silence the calls behind it -- and therefore cannot notice
the session is gone on its own.

Constraint: a published tool call reaches a terminal status while a client can observe one
Constraint: cleanup does not publish through the path that fails the session
Rejected: a fifth guard | four rounds of guards produced a fifth route
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: page throws mid-replay, cleanup publication fails partway, unnameable call, call the transcript never resolved, session closed concurrently with the cleanup
Not-tested: a session removed between the state read and the first close

* fix(acp): stop emitting terminals to a session that is already closed

Round 5 argued the terminal obligation ends where the session ends, and that
argument only holds if nothing is emitted afterwards. The review measured
otherwise: expected no post-close terminal, received `tool-race`; expected only
`tool-before-close`, also received `tool-after-close`. The comment asserted a
boundary the code kept writing past.

`#closeReplayToolCall` bypasses `#publishSessionUpdate` on purpose, so a failure
closing one call cannot silence the calls behind it. That bypass also skipped the
check that the session still exists, and the two are separate concerns -- only the
publish path needed avoiding. The session check is back, and the round-4 property
survives with it.

Constraint: no terminal frame is addressed to a session the client has closed
Constraint: one failed close still does not silence the rest
Rejected: keeping the bypass wholesale | it conflated the publish path with session state
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: close during an unreplayable result, close between cleanup entries, page throws mid-replay, cleanup publication fails partway, unnameable call, call the transcript never resolved
Not-tested: a close that lands between the session read and the first publish
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