[fix] Transcript replay respects a cancelled interaction's terminal status - #5912
Conversation
…t replay New `fetchCancelledClientToolTokensAtom` (mirrors `fetchSessionRecordsAtom`'s imperative-fetch-through-the-shared-cache pattern): a best-effort, never-throwing query for a session's `client_tool` interactions, reduced to the set of tokens whose `session_interactions.status` is `cancelled`. This is the join key transcript replay needs but the durable record log alone doesn't carry: `session_interactions.token` equals the record's `toolCallId` (confirmed against a live 8180 row: token `call_n7Gec...` == the `interaction_request` record's `toolCallId`). Consumed by the next commit.
…atus
After a hard reload, a client-tool interaction whose session_interactions row
was already terminally cancelled (e.g. the stale-interaction sweep) replayed
as fully PENDING: the elicitation form came back at question 1, blank, with
live Accept/Decline/Dismiss buttons, stacked above the real current pending
interaction. Reload-proof — live evidence on the 8180 dev stack, session
3975e362-f64c-4e2d-8f4f-4f36c584bd91. Clicking "Decline" on the resurrected
form was a pure client-side cosmetic no-op: the chip flipped to "Declined the
request." but the network log showed zero mutating requests and the
session_interactions row stayed byte-identical (status=cancelled, original
updated_at) — confirmed live, so this is entirely a replay bug, not a missing
backend endpoint.
Root cause: `replayClientTool` rebuilds a parked client tool's part straight
from its own `interaction_request` record, with no way to know the
interaction's later lifecycle — that lives in `session_interactions`, not the
record log. A normal live settle (connect, an elicitation answer) DOES leave a
trace: the browser's `addToolOutput` resubmits the result and the runner
re-emits it as a `tool_result` record that settles the part on replay too, no
join needed. But a server-side cancellation leaves the transcript with nothing
but the original request.
`transcriptToMessages` now takes an optional `cancelledClientToolTokens` set
and, after the full record sweep (so a real, later `tool_result` always wins),
settles any still-`input-available` client-tool part whose token is in that
set — mirroring each widget's OWN cancelled-terminal shape so the resurrected
part renders exactly like a live cancel (ConnectOutput `{connected:false,
reason:"cancelled"}`, ElicitationResult `{action:"cancel"}`), never as an
interactive form. `loadSessionMessages` fetches the join via the new
`fetchCancelledClientToolTokensAtom` alongside records, best-effort (a failure
there degrades to today's behavior, never blocks the transcript from loading).
7 new tests in the transcriptToMessages family: cancelled -> inert (both known
client-tool kinds), pending -> unaffected (existing behavior, both "not in the
set" and "option omitted entirely"), a real tool_result still overriding a
stale cancelled-token entry, and an unregistered kind still settling instead
of crashing.
Byte-parity re-sync of packages/agenta-chat/src/assets/{transcriptToMessages,
loadSession}.ts with the OSS originals (per this file pair's own maintenance
convention — see their header comments) for the previous commit's fix: a
terminally-cancelled client-tool interaction now replays inert instead of as
a live, answerable form.
Also updates loadSession.test.ts's `@agenta/entities/session` mock to stub
the new `fetchCancelledClientToolTokensAtom` (defaults to an empty set — that
file's own assertions only cover the records half; the join itself is covered
by the 7 new tests mirrored into transcriptToMessages.test.ts here).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughSummary by CodeRabbit
WalkthroughSession hydration now loads cancelled client-tool tokens with session records. Transcript replay settles matching elicitation and connection tools as inert outputs. Real tool results remain authoritative, and failures return an empty token set. ChangesCancelled client-tool replay
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant SessionLoader
participant SessionState
participant TranscriptConverter
participant MessageState
SessionLoader->>SessionState: Fetch records and cancelled client-tool tokens
SessionState-->>SessionLoader: Return records and token Set
SessionLoader->>TranscriptConverter: Convert records with token Set
TranscriptConverter->>TranscriptConverter: Settle matching client-tool parts
TranscriptConverter-->>MessageState: Return hydrated messages
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
web/packages/agenta-entities/src/session/state/interactionStatus.ts (1)
33-37: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winFilter cancelled interactions in the API request.
Line 33 fetches every
client_toolinteraction and discards non-cancelled entries locally. Passstatus: "cancelled"toqueryInteractionsso hydration requests only the tokens that this flow needs.Proposed fix
-const interactions = await queryInteractions({sessionId, projectId, kind: "client_tool"}) +const interactions = await queryInteractions({ + sessionId, + projectId, + kind: "client_tool", + status: "cancelled", +})
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: 91a5ad60-64b7-4218-af9b-b2fb2b8406b3
📒 Files selected for processing (9)
web/oss/src/components/AgentChatSlice/assets/loadSession.tsweb/oss/src/components/AgentChatSlice/assets/transcriptToMessages.test.tsweb/oss/src/components/AgentChatSlice/assets/transcriptToMessages.tsweb/packages/agenta-chat/src/assets/loadSession.tsweb/packages/agenta-chat/src/assets/transcriptToMessages.tsweb/packages/agenta-chat/tests/unit/assets/loadSession.test.tsweb/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.tsweb/packages/agenta-entities/src/session/index.tsweb/packages/agenta-entities/src/session/state/interactionStatus.ts
Railway Preview Environment
Updated at 2026-08-10T16:51:26.006Z |
Context
Live evidence, session
3975e362-f64c-4e2d-8f4f-4f36c584bd91on the 8180 dev stack: after a hard reload, a client-tool interaction whosesession_interactionsrow was already terminallycancelled(e.g. the stale-interaction sweep) re-rendered as fully PENDING. The elicitation form came back at question 1, blank, with live Accept/Decline/Dismiss buttons, stacked above the real, current pending interaction. This is very plausibly what was actually seen as "no UI showed up" in the connect-flow investigation (#5909): the dead earlier form rendered prominently and hid the real one below it.Live-verified the extent of the bug before this fix: clicking "Decline" on the resurrected form is a pure client-side cosmetic no-op. The chip flips to "Declined the request.", but the network log shows zero mutating requests and the
session_interactionsrow stays byte-identical (status=cancelled, originalupdated_at). The real pending interaction beside it is untouched. So the UI lies twice (a dead form renders live, then a click on it pretends to work), but there is no backend gap to close for this symptom. The fix is entirely in transcript replay.Changes
replayClientTool(intranscriptToMessages.ts) rebuilds a parked client tool's part straight from its owninteraction_requestrecord. That record has no way to know the interaction's later lifecycle — that lives insession_interactions, a separate table, not the record log. A normal live settle (a successful connect, an answered elicitation) does leave a trace: the browser'saddToolOutputresubmits the result, and the runner re-emits it as atool_resultrecord that settles the part on replay too, so no join is needed for that path. But a server-side cancellation leaves the transcript with nothing but the original request, forever.transcriptToMessagesnow takes an optionalcancelledClientToolTokens: ReadonlySet<string>and, after the full record sweep completes (so a real, latertool_resultalways wins over this fallback), settles any part still stuck atinput-availablewhose token is in that set. The synthesized output mirrors each widget's OWN cancelled-terminal shape, so a resurrected part renders exactly like a live cancel:The join key is
session_interactions.token, which equals the record'stoolCallId— confirmed live against an 8180 row (tokencall_n7Gec...== theinteraction_requestrecord'stoolCallId). NewfetchCancelledClientToolTokensAtomin@agenta/entities/session(mirrorsfetchSessionRecordsAtom's "imperative fetch through the shared query cache" pattern) queries the session'sclient_toolinteractions and reduces them to the cancelled tokens. It's best-effort: any failure resolves to an empty set rather than throwing, so a resurrected-form miss just degrades to today's behavior instead of blocking the whole transcript from loading.loadSessionMessagesfetches it alongside records and threads it through.Same treatment for the
agenta-chatpackage's byte-parity copy of both files (per that pair's own "keep byte-parity if either side changes" convention).Tests / notes
transcriptToMessagestest family: cancelled -> inert for both known client-tool kinds, pending -> unaffected (both "token not in the set" and "option omitted entirely" — the existing behavior, unchanged), a realtool_resultstill overriding a stale cancelled-token entry, and an unregistered client-tool kind still settling (empty output) instead of crashing.loadSession.test.ts's@agenta/entities/sessionmock to stub the new atom.tsc --noEmitclean acrossoss,@agenta/entities,@agenta/chat.pnpm turbo run build --filter=@agenta/entities --filter=@agenta/chatclean.pnpm turbo run lint --filter=@agenta/oss --filter=@agenta/chat --filter=@agenta/entitiesclean (no new warnings).@agenta/ossAgentChatSlice (167 tests),@agenta/chat(252 tests),@agenta/entities(954 tests).What to QA
tool_resultin its transcript) still replays as connected/submitted, not as cancelled.