Kaynağa Gözat

Merge remote-tracking branch 'origin/split/agent-factory' into split/session-persistence-sqlite

# Conflicts:
#	docs/adr/0018-session-persistence.md
Tianyi Cui 4 ay önce
ebeveyn
işleme
dbaef018cc

+ 1 - 1
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 proven 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. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows.
 - **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.
 - **Resume is an async factory, not a change to synchronous create.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.

+ 1 - 1
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 <text>" 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 `<repo-root>/.sessions/` (a session with no cwd goes in the `_no-cwd/` bucket, one `.jsonl` log per session). Clean up with: `rm -rf .sessions`

+ 1 - 1
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.
 

+ 2 - 2
packages/session-persistence/README.md

@@ -10,14 +10,14 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
 |---|---|
 | `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
 | `append(id, events): Promise<void>` | 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<SessionMeta[]>` | 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<void>` | 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.

+ 11 - 8
packages/session-persistence/src/index.ts

@@ -93,14 +93,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[] }>
 

+ 41 - 0
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<Contrac
       }
     })
 
+    it('crash recovery: an interrupted tool call gets a synthetic error result so resume is a valid transcript', async () => {
+      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 {

+ 62 - 3
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<CallId, { step: number }>()
   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 } })

+ 125 - 0
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')
+  })
+})