From 2bae18f8118b08238952af3f0471a127a853f83b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:50:52 +0800 Subject: [PATCH] fix(session): synthesize tool results for interrupted tool calls on crash recovery (review #33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop logs the assistant/message (carrying tool-call blocks) BEFORE running the tools, so a crash mid-tool leaves durable tool calls with no matching tool/result. interruptedTurnClosers only added step/end + turn/end, so a resumed session's deriveMessages() replayed a dangling assistant tool-call — which every provider rejects as an invalid transcript on the next request. interruptedTurnClosers now scans the interrupted turn for tool-call blocks without a matching tool/result and synthesizes an error tool/result for each (before the step/end), so the rehydrated history is a valid transcript. Adds a dedicated repair.spec.ts and a shared-contract case proving both backends pair every orphaned call with a result. Docs (ADR 0018, both persistence READMEs, load() JSDoc) updated. Also fixes the echo-agent README session-cleanup path: demo:echo runs from the repo root, so sessions land in /.sessions/_no-cwd/, not examples/echo-agent/.sessions/ (review #33). --- docs/adr/0018-session-persistence.md | 2 +- examples/echo-agent/README.md | 2 +- packages/session-persistence-jsonl/README.md | 2 +- packages/session-persistence/README.md | 4 +- packages/session-persistence/src/index.ts | 19 +-- .../session-persistence/tests/contract.ts | 41 ++++++ packages/session/src/repair.ts | 65 ++++++++- packages/session/tests/repair.spec.ts | 125 ++++++++++++++++++ 8 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 packages/session/tests/repair.spec.ts diff --git a/docs/adr/0018-session-persistence.md b/docs/adr/0018-session-persistence.md index 2a90377d4f..5f211bef51 100644 --- a/docs/adr/0018-session-persistence.md +++ b/docs/adr/0018-session-persistence.md @@ -18,7 +18,7 @@ Persistence is an abstract **capability seam** ([ADR 0009](0009-capability-seams Key choices recorded here because they are durable, contested, and surprising: - **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. -- **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable. +- **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered, then a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). The synthetic tool results matter for resume correctness: the loop logs the `assistant/message` (carrying the `tool-call` blocks) BEFORE running the tools, so a crash mid-tool leaves calls without results; `deriveMessages()` would then replay a dangling assistant tool-call, which every provider rejects as an invalid transcript on the next request. Answering each orphaned call with an error result keeps the rehydrated history valid. `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable. - **File backend canonical, DB backend a drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. A future `dsh-session-persistence-sqlite` is a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL). - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionMeta` (`SessionHeader & SessionSummary`) owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. - **`load` returns a resumable event log, not just bytes.** `load(sessionId)` yields the `SessionMeta` plus the committed `SessionEvent[]` (through the last complete `turn/end`), shaped so a caller can reconstruct a live session with the loaded events as seed (so `lastTurnNumber`/`deriveMessages` continue) on the SAME session id. The agent-facing create/resume factory that consumes this is a separate seam (a follow-up on `ctx.agents`); the persistence layer deliberately stops at the `load` primitive and does NOT reach into the loop. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever), so any resume path built on this rejects with a clear error when the backend is absent. diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md index f274d1ddbd..a5311e1fd5 100644 --- a/examples/echo-agent/README.md +++ b/examples/echo-agent/README.md @@ -31,4 +31,4 @@ node --expose-internals --import tsx examples/echo-agent/start.ts Type a message and press Enter. "echo " triggers a tool call round-trip (the mock model requests the `echo` tool, which echoes the text uppercased, and the next model step acknowledges it). -The session is persisted under `examples/echo-agent/.sessions/` (per-cwd subdirectory, one `.jsonl` log per session). Clean up with: `rm -rf examples/echo-agent/.sessions` +The session is persisted under `.sessions/` relative to the directory you launch the demo from. `pnpm run demo:echo` runs from the repo root, so the logs land in `/.sessions/` (a session with no cwd goes in the `_no-cwd/` bucket, one `.jsonl` log per session). Clean up with: `rm -rf .sessions` diff --git a/packages/session-persistence-jsonl/README.md b/packages/session-persistence-jsonl/README.md index 8d1d55d9a7..e3df89931c 100644 --- a/packages/session-persistence-jsonl/README.md +++ b/packages/session-persistence-jsonl/README.md @@ -24,7 +24,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically (temp-write + `fsync` + rename) on the first `append`. A created-but-never-appended session leaves nothing on disk and is absent from `has`/`list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. -- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events (a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`), returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See ADR 0018. +- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See ADR 0018. - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. - **Format version.** Only v1 is supported; `load` rejects an unknown version. A future format change requires a version bump + migration. diff --git a/packages/session-persistence/README.md b/packages/session-persistence/README.md index 8f534fbc36..686b27e506 100644 --- a/packages/session-persistence/README.md +++ b/packages/session-persistence/README.md @@ -10,14 +10,14 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l |---|---| | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | -| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | +| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. | | `has(id)` / `delete(id)` | Existence / removal. A zero-event lazily-materialized session is absent from `has`/`list`. | | `update(id, summary): Promise` | Update mutable `SessionSummary` fields without touching the append-only log. | ## Invariants every backend must honor -- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (`step/end?`+`turn/end {interrupted}`) to balance the log. Only a never-fully-written torn tail fragment is discarded. +- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded. - **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq. - **JSON-serializable data.** `append` rejects non-serializable `event.data`; backends snapshot each event when buffering (the live `session.events` object is mutable). - **Durability.** `append` returns only once the batch is durable. diff --git a/packages/session-persistence/src/index.ts b/packages/session-persistence/src/index.ts index e770b5b86c..1ebe31c8d0 100644 --- a/packages/session-persistence/src/index.ts +++ b/packages/session-persistence/src/index.ts @@ -90,14 +90,17 @@ export abstract class SessionPersistence extends Service { * `turn/end`. Those events are PRESERVED — a single turn can be huge in a * long-horizon task, so truncating it would destroy real work — and `load` * CLOSES the orphaned turn by durably appending the minimal synthetic boundary - * events (a `step/end` if a step was open, then a `turn/end` carrying the - * `{ kind: 'interrupted' }` reason). The returned `events` therefore end on a - * balanced `turn/end` and are immediately usable as a session seed. Only a - * never-fully-written TORN tail fragment (a half-written final record) is - * discarded. Returned events are contiguous (`events[i].seq === i`); a parse - * error or a `seq` gap in the COMMITTED region (at or before the last real - * `turn/end`) makes the session unloadable (reject). Rejects an unknown format - * `version`. See ADR 0018 for the crash-recovery contract. + * events: an error `tool/result` for every `tool-call` the crash left + * unanswered (so the rehydrated history is a valid provider transcript — a + * dangling assistant tool-call is otherwise rejected), then a `step/end` if a + * step was open, then a `turn/end` carrying the `{ kind: 'interrupted' }` + * reason. The returned `events` therefore end on a balanced `turn/end` and are + * immediately usable as a session seed. Only a never-fully-written TORN tail + * fragment (a half-written final record) is discarded. Returned events are + * contiguous (`events[i].seq === i`); a parse error or a `seq` gap in the + * COMMITTED region (at or before the last real `turn/end`) makes the session + * unloadable (reject). Rejects an unknown format `version`. See ADR 0018 for + * the crash-recovery contract. */ abstract load(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> diff --git a/packages/session-persistence/tests/contract.ts b/packages/session-persistence/tests/contract.ts index 189a4e10c2..f8535a70f9 100644 --- a/packages/session-persistence/tests/contract.ts +++ b/packages/session-persistence/tests/contract.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session' +import { CallId } from '@deepseek-ai/dsh-llm' import type { SessionPersistence } from '../src/index.ts' /** A backend under test plus its teardown. */ @@ -102,6 +103,46 @@ export function runPersistenceContract(name: string, make: () => Promise { + const { persistence, dispose } = await make() + try { + const m = meta('interrupted-toolcall') + await persistence.create(m) + await persistence.append(m.id, oneTurnLog()) // turn 1, committed (seqs 0..5) + // Turn 2 crashed AFTER the assistant message asked for a tool call but + // BEFORE the tool/result was written (the loop runs tools after logging + // the assistant message — a process killed mid-tool lands exactly here). + await persistence.append(m.id, [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 8, time: 9, data: { turn: 2, step: 1, content: [ + { type: 'tool-call', id: CallId('call-x'), name: 'bash', arguments: '{}' }, + ] } }, + ]) + + const loaded = await persistence.load(m.id) + // The orphaned call is answered by a synthetic error tool/result BEFORE + // step/end + turn/end {interrupted}, so the step (and turn) are balanced + // and a resumed session derives a valid transcript (no dangling call). + expect(loaded.events.map(e => e.type)).toEqual([ + 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1 + 'turn/start', 'step/start', 'assistant/message', 'tool/result', 'step/end', 'turn/end', // turn 2 + ]) + const synthetic = loaded.events.find(e => e.type === 'tool/result') + expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({ + callId: CallId('call-x'), isError: true, error: { code: 'interrupted' }, + }) + // The synthetic result carries the SAME callId as the orphaned tool-call, + // so deriveMessages() pairs them — no provider-invalid dangling call. + const call = loaded.events.findLast(e => e.type === 'assistant/message') + const callId = call?.type === 'assistant/message' + && call.data.content.find(b => b.type === 'tool-call') + expect(callId && callId.type === 'tool-call' && callId.id).toBe(CallId('call-x')) + } finally { + await dispose() + } + }) + it('has()/list() exclude a created-but-never-appended (zero-event) session', async () => { const { persistence, dispose } = await make() try { diff --git a/packages/session/src/repair.ts b/packages/session/src/repair.ts index c09d15eee6..6a3f60a681 100644 --- a/packages/session/src/repair.ts +++ b/packages/session/src/repair.ts @@ -7,11 +7,26 @@ * in a long-horizon task (many steps, large tool output), so those events MUST * be preserved — truncating the turn would silently destroy real work. Instead, * on reload the backend CLOSES the orphaned turn by appending the minimal - * synthetic boundary events (a `step/end` if a step was still open, then a - * `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason). + * synthetic boundary events: + * + * 1. an error `tool/result` for every `tool-call` in the interrupted turn that + * never got its matching `tool/result` (so the rehydrated history is a + * VALID provider transcript — see below), + * 2. a `step/end` if a step was still open, then + * 3. a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason. + * * The marker records that the turn was cut short by a crash, not completed by * the model. See ADR 0018. * + * Why the synthetic tool results matter: `deriveMessages()` renders the + * `tool-call` blocks inside a durable `assistant/message` but only emits a + * matching tool-result when a `tool/result` EVENT exists. A crash between the + * assistant message and its tool results (the loop runs the tools AFTER logging + * the assistant message, so a process killed mid-tool leaves the calls without + * results) would otherwise reload a history with a dangling assistant tool-call + * — which every provider rejects as an invalid transcript on the next request. + * Synthesizing an error result per orphaned call keeps resume safe. + * * This module computes those synthetic closers from an event list; the backend * returns them inline from `load` (so the reconstructed session is balanced and * immediately usable) and persists them on the first post-load `append`. @@ -19,6 +34,7 @@ * @module @deepseek-ai/dsh-session/repair */ +import type { CallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from './types.ts' /** @@ -29,6 +45,11 @@ import type { SessionEvent } from './types.ts' * "future" time). Returns an empty array when the log is already balanced * (ends on a `turn/end`, or is empty) — the common, non-crash case. * + * The closers, in order: an error `tool/result` for each unmatched `tool-call` + * in the interrupted turn, then a `step/end` if a step is open, then the + * `turn/end {interrupted}`. The tool-results come first so a step that issued + * tool calls is balanced (every call has a result) before its `step/end`. + * * Only the LAST turn can be open: the invariants plugin guarantees a `turn/end` * before any later `turn/start`, so an interior open turn is impossible in a * valid committed log. Likewise at most one step is open within that turn. @@ -36,14 +57,22 @@ import type { SessionEvent } from './types.ts' export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] { let openTurn: number | null = null let openStep: number | null = null + // Track tool calls vs. their results WITHIN the currently-open turn only: a + // call is "pending" until its matching tool/result arrives. Reset at every + // turn boundary so a committed earlier turn (already balanced) never leaks a + // phantom pending call into the interrupted-turn repair. + const pendingCalls = new Map() for (const event of events) { switch (event.type) { case 'turn/start': openTurn = event.data.turn + openStep = null + pendingCalls.clear() break case 'turn/end': openTurn = null openStep = null + pendingCalls.clear() break case 'step/start': openStep = event.data.step @@ -51,6 +80,16 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session case 'step/end': openStep = null break + case 'assistant/message': + // The assistant message carries the tool-call blocks; each is pending + // until a tool/result event with the same callId is logged. + for (const block of event.data.content) { + if (block.type === 'tool-call') pendingCalls.set(block.id, { step: event.data.step }) + } + break + case 'tool/result': + pendingCalls.delete(event.data.callId) + break // Other event types do not move the turn/step boundary cursor. default: break @@ -69,7 +108,27 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session const time = last.time const closers: SessionEvent[] = [] - // Close an open step first — a turn/end while a step is open is an invariant + // Synthesize an error tool/result for each tool-call left unanswered by the + // crash, so deriveMessages() yields a valid provider transcript on resume (a + // dangling assistant tool-call is rejected by every provider). Insertion + // order follows the Map (insertion = log order of the assistant messages). + for (const [callId, { step }] of pendingCalls) { + closers.push({ + type: 'tool/result', + seq: seq++, + time, + data: { + turn: openTurn, + step, + callId, + content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }], + isError: true, + error: { name: 'InterruptedError', code: 'interrupted' }, + }, + }) + } + + // Close an open step next — a turn/end while a step is open is an invariant // violation, so the step's boundary must be synthesized before the turn's. if (openStep !== null) { closers.push({ type: 'step/end', seq: seq++, time, data: { turn: openTurn, step: openStep } }) diff --git a/packages/session/tests/repair.spec.ts b/packages/session/tests/repair.spec.ts new file mode 100644 index 0000000000..893015b218 --- /dev/null +++ b/packages/session/tests/repair.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' +import { interruptedTurnClosers } from '../src/index.ts' +import type { SessionEvent } from '../src/index.ts' + +/** + * Unit coverage for the crash-recovery closer synthesis. The persistence + * contract exercises it end-to-end through both backends; these tests pin the + * pure function's branches directly — especially the synthetic error + * `tool/result` for a tool call the crash left unanswered (without it a + * resumed session replays a dangling assistant tool-call and the provider + * rejects the transcript). + */ + +const userTurnStart = (turn: number, seq: number): SessionEvent => + ({ type: 'turn/start', seq, time: seq, data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + +describe('interruptedTurnClosers', () => { + it('returns nothing for a balanced log (ends on turn/end)', () => { + const balanced: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + expect(interruptedTurnClosers(balanced)).toEqual([]) + }) + + it('returns nothing for an empty log', () => { + expect(interruptedTurnClosers([])).toEqual([]) + }) + + it('closes an open turn with no open step (turn/end {interrupted} only)', () => { + const events: SessionEvent[] = [userTurnStart(1, 0)] + const closers = interruptedTurnClosers(events) + expect(closers.map(e => e.type)).toEqual(['turn/end']) + const end = closers[0]! + expect(end.seq).toBe(1) + expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' }) + }) + + it('closes an open step before the turn (step/end then turn/end)', () => { + const events: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + ] + const closers = interruptedTurnClosers(events) + expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end']) + expect(closers.map(e => e.seq)).toEqual([2, 3]) + }) + + it('synthesizes an error tool/result for a tool-call the crash left unanswered', () => { + // A step issued one tool call (in the assistant message) but crashed before + // the tool/result was logged — the classic mid-tool crash. + const events: SessionEvent[] = [ + userTurnStart(2, 0), + { type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [ + { type: 'text', text: 'calling a tool' }, + { type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' }, + ] } }, + ] + const closers = interruptedTurnClosers(events) + // tool/result (for the orphaned call) → step/end → turn/end, contiguous seqs. + expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end']) + expect(closers.map(e => e.seq)).toEqual([3, 4, 5]) + const result = closers[0]! + expect(result.type === 'tool/result' && result.data).toMatchObject({ + turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' }, + }) + }) + + it('does NOT synthesize a result for a tool-call that already has one', () => { + const events: SessionEvent[] = [ + userTurnStart(2, 0), + { type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [ + { type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' }, + ] } }, + { type: 'tool/result', seq: 3, time: 3, data: { turn: 2, step: 1, callId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }], isError: false } }, + ] + // The call is answered, so only the open step + turn need closing. + const closers = interruptedTurnClosers(events) + expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end']) + }) + + it('synthesizes results only for the still-open turn, not a committed earlier turn', () => { + // Turn 1 completed with its own tool call+result (balanced). Turn 2 crashed + // with an unanswered call. Only turn 2's call must get a synthetic result. + const events: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [ + { type: 'tool-call', id: CallId('old-call'), name: 'bash', arguments: '{}' }, + ] } }, + { type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('old-call'), content: [], isError: false } }, + { type: 'step/end', seq: 4, time: 4, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 5, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, + userTurnStart(2, 6), + { type: 'step/start', seq: 7, time: 7, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 8, time: 8, data: { turn: 2, step: 1, content: [ + { type: 'tool-call', id: CallId('new-call'), name: 'bash', arguments: '{}' }, + ] } }, + ] + const closers = interruptedTurnClosers(events) + expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end']) + const result = closers[0]! + expect(result.type === 'tool/result' && result.data.callId).toBe('new-call') + }) + + it('synthesizes a result for each of multiple unanswered calls, in log order', () => { + const events: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [ + { type: 'tool-call', id: CallId('call-a'), name: 'bash', arguments: '{}' }, + { type: 'tool-call', id: CallId('call-b'), name: 'bash', arguments: '{}' }, + ] } }, + // call-a got answered before the crash; call-b did not. + { type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('call-a'), content: [], isError: false } }, + ] + const closers = interruptedTurnClosers(events) + expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end']) + const result = closers[0]! + expect(result.type === 'tool/result' && result.data.callId).toBe('call-b') + }) +})