Jelajahi Sumber

refactor(agent): simplify inbox-driven turn admission

_Kerman 1 bulan lalu
induk
melakukan
dbdf270af0
100 mengubah file dengan 436 tambahan dan 442 penghapusan
  1. 3 3
      apps/cli/src/headless.ts
  2. 1 1
      apps/cli/tests/tui-keyless-smoke.e2e.ts
  3. 9 9
      apps/cli/tests/tui.snapshot.ts
  4. 1 1
      apps/web/tests/seeded-history.e2e.ts
  5. 11 11
      docs/cordis-catalog/events.md
  6. 58 19
      docs/cordis-catalog/services.md
  7. 1 1
      examples/headless-agent/tests/subagent-inheritance.snapshot.ts
  8. 3 1
      packages/acp/acp/tests/turns.spec.ts
  9. 12 17
      packages/client/connection/src/client/fixture.ts
  10. 3 3
      packages/client/runtime/src/client/sessions/conversation.ts
  11. 6 3
      packages/client/runtime/src/client/sessions/failure-display.ts
  12. 2 2
      packages/client/runtime/src/client/sessions/request-inspection.ts
  13. 15 8
      packages/client/runtime/src/client/sessions/session.ts
  14. 6 1
      packages/client/runtime/tests/event-script.ts
  15. 5 10
      packages/client/runtime/tests/request-inspection.spec.ts
  16. 41 38
      packages/client/runtime/tests/session.spec.ts
  17. 1 1
      packages/compact/compact-basic/src/index.ts
  18. 4 4
      packages/compact/compact-basic/tests/compact-basic.spec.ts
  19. 1 1
      packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
  20. 1 1
      packages/compact/compact-basic/tests/loader-composition.spec.ts
  21. 8 8
      packages/compact/compact-basic/tests/manual-compact.spec.ts
  22. 1 1
      packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts
  23. 9 9
      packages/compact/compact/tests/invariant.spec.ts
  24. 1 2
      packages/compact/compact/tests/tool-pairing.spec.ts
  25. 2 2
      packages/context/time-context/tests/invariant.spec.ts
  26. 2 5
      packages/context/time-context/tests/time-context.spec.ts
  27. 3 6
      packages/context/tmux-context/tests/tmux-context.spec.ts
  28. 3 11
      packages/cordis/tool-cordis/src/api-catalog.ts
  29. 4 4
      packages/core/agent-loop/src/agent.ts
  30. 3 3
      packages/core/agent-loop/tests/contract-regressions.spec.ts
  31. 1 0
      packages/core/agent-loop/tests/request-reconstruction.spec.ts
  32. 4 4
      packages/core/agent-loop/tests/resume.spec.ts
  33. 1 1
      packages/core/agent-loop/tests/tool-calls.spec.ts
  34. 0 1
      packages/core/agent/tests/agent.spec.ts
  35. 4 0
      packages/core/session/src/invariant.ts
  36. 5 1
      packages/core/session/src/repair.ts
  37. 6 5
      packages/core/session/src/types.ts
  38. 1 1
      packages/core/session/tests/fork.spec.ts
  39. 14 14
      packages/core/session/tests/invariant.spec.ts
  40. 1 1
      packages/core/session/tests/properties.spec.ts
  41. 5 5
      packages/core/session/tests/repair.spec.ts
  42. 1 1
      packages/core/session/tests/request-header.spec.ts
  43. 10 10
      packages/core/session/tests/session.spec.ts
  44. 4 4
      packages/core/session/tests/surface.spec.ts
  45. 2 2
      packages/core/tools/tests/invariant.spec.ts
  46. 1 1
      packages/examples/cli-demo/tests/cli.spec.ts
  47. 0 2
      packages/fs/tool-str-replace-editor/tests/tools.spec.ts
  48. 0 3
      packages/goal/command-goal/tests/command-goal.spec.ts
  49. 1 1
      packages/goal/goal-session/tests/goal-session.spec.ts
  50. 2 2
      packages/goal/goal-session/tests/invariant.spec.ts
  51. 3 6
      packages/goal/goal/tests/goal.spec.ts
  52. 0 1
      packages/goal/goal/tests/projection.spec.ts
  53. 1 2
      packages/goal/tool-goal/tests/tool-goal.spec.ts
  54. 2 2
      packages/hooks/hook-protocol/tests/invariant.spec.ts
  55. 0 1
      packages/host/apiproxy/src/api/index.ts
  56. 1 1
      packages/host/apiproxy/tests/api-proxy-cold.spec.ts
  57. 1 1
      packages/host/apiproxy/tests/api-proxy-fork.spec.ts
  58. 1 1
      packages/host/apiproxy/tests/api-proxy-rename.spec.ts
  59. 1 1
      packages/host/apiproxy/tests/api-proxy-view.spec.ts
  60. 0 3
      packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
  61. 3 7
      packages/llm/llm-retry/tests/invariant.spec.ts
  62. 2 6
      packages/llm/llm-retry/tests/persistence.spec.ts
  63. 2 2
      packages/llm/llm-retry/tests/retry.spec.ts
  64. 2 2
      packages/llm/llm-retry/tests/transport-recovery.spec.ts
  65. 8 5
      packages/plan/plan-mode/src/index.ts
  66. 3 4
      packages/plan/plan-mode/tests/invariant.spec.ts
  67. 6 6
      packages/plan/plan-mode/tests/plan-mode.spec.ts
  68. 1 1
      packages/plan/plan-mode/tests/projection.spec.ts
  69. 6 6
      packages/pty/pty-local/tests/index.spec.ts
  70. 2 2
      packages/pty/pty-local/tests/local.spec.ts
  71. 0 2
      packages/pty/pty/tests/service.spec.ts
  72. 0 2
      packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts
  73. 0 2
      packages/pty/tool-bash-persistent/tests/tools.spec.ts
  74. 2 2
      packages/pty/tool-pty/tests/loader-composition.spec.ts
  75. 2 2
      packages/pty/tool-pty/tests/tools.spec.ts
  76. 18 18
      packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
  77. 3 3
      packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts
  78. 9 9
      packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts
  79. 2 2
      packages/session-persistence/session-persistence/tests/contract.ts
  80. 18 18
      packages/session-persistence/session-persistence/tests/coordinator-contract.ts
  81. 9 9
      packages/session-persistence/session-persistence/tests/persistence.spec.ts
  82. 2 2
      packages/session-projection/session-projection-cache/tests/cache.spec.ts
  83. 4 4
      packages/session-projection/session-projection/tests/registry.spec.ts
  84. 1 1
      packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
  85. 2 2
      packages/session-query/session-query/tests/search-helpers.spec.ts
  86. 1 1
      packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts
  87. 1 1
      packages/session-title/session-title-llm/tests/llm.spec.ts
  88. 1 1
      packages/session-title/session-title/tests/persistence.spec.ts
  89. 4 4
      packages/session-title/session-title/tests/provider.spec.ts
  90. 2 2
      packages/session-title/session-title/tests/service-contracts.spec.ts
  91. 0 6
      packages/skill/tool-skill/tests/tool-skill.spec.ts
  92. 4 4
      packages/support/acp-snapshot/tests/harness.spec.ts
  93. 2 2
      packages/support/llm-replay/tests/llm-replay.spec.ts
  94. 0 2
      packages/tasks/tasks-local/tests/tasks.spec.ts
  95. 1 1
      packages/telemetry/session-telemetry-otel/tests/otel.spec.ts
  96. 6 6
      packages/telemetry/session-telemetry/tests/telemetry.spec.ts
  97. 1 1
      packages/todo/tool-todo/tests/projection.spec.ts
  98. 2 2
      packages/ui/permission/tests/permission.spec.ts
  99. 0 1
      packages/ui/tui/tests/harness.ts
  100. 11 25
      packages/ui/tui/tests/tui.snapshot.ts

+ 3 - 3
apps/cli/src/headless.ts

@@ -32,9 +32,9 @@ async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>)
 
 /**
  * Consume mux frames until the task turn ends, per the cli-demo runOneShot
- * correlation precedent: anchor on the first turn/start whose trigger kind is
- * 'message' (startup-injected turns are skipped), aggregate text from that
- * turn's assistant/message events (last one wins), finish on its turn/end.
+ * correlation precedent: the stream opens immediately before the prompt, so
+ * its first observed turn/start owns the task. Aggregate text from that turn's
+ * assistant/message events (last one wins), then finish on its turn/end.
  */
 async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>, sessionId: SessionId): Promise<TurnOutcome> {
   let targetTurn: number | undefined

+ 1 - 1
apps/cli/tests/tui-keyless-smoke.e2e.ts

@@ -92,7 +92,7 @@ async function seedResumeSession(cwd: string): Promise<void> {
     { type: 'step/end', seq: 5, time: 1_700_000_000_006, data: { turn: 1, step: 1 } },
     { type: 'session/title', seq: 6, time: 1_700_000_000_007, data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } },
     { type: 'todo/write', seq: 7, time: 1_700_000_000_008, data: { todos: [{ content: 'Preserve restored state', status: 'in_progress' }] } },
-    { type: 'turn/end', seq: 8, time: 1_700_000_000_009, data: { turn: 1, reason: { kind: 'completed' } } },
+    { type: 'turn/end', seq: 8, time: 1_700_000_000_009, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
   ]
   const file = logPath(join(cwd, '.sessions'), sessionCwd, id, 'none')
   await mkdir(dirname(file), { recursive: true })

+ 9 - 9
apps/cli/tests/tui.snapshot.ts

@@ -509,14 +509,14 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
       expect(await terminal.snapshot({ includeScrollback: true }))
         .toContain('/compact — Compact older conversation history')
 
-      const compact = ctx.compact as DeferredSnapshotCompactService
+      const compact = ctx.compact as unknown as DeferredSnapshotCompactService
       const inbox: string[] = []
       manualOrder = []
-      ctx.on('agent/inbox/enqueue', (subject, item) => {
-        if (subject === agent) inbox.push(`enqueue:${item.placement}:${item.id}`)
+      ctx.on('agent/inbox/inserted', (subject, { message }) => {
+        if (subject === agent) inbox.push(`inserted:${message.id}`)
       })
-      ctx.on('agent/inbox/dequeue', (subject, message) => {
-        if (subject === agent) inbox.push(`dequeue:${message.id}`)
+      ctx.on('agent/inbox/claimed', (subject, { message }) => {
+        if (subject === agent) inbox.push(`claimed:${message.id}`)
       })
       ctx.on('session/event', (session, event) => {
         if (session !== agent.session) return
@@ -531,7 +531,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
         if (event.type === 'user/message'
           && event.data.source.kind === 'plugin'
           && event.data.source.plugin === 'compact') manualOrder?.push('checkpoint')
-        if (event.type === 'turn/start') manualOrder?.push(`turn/start:${event.data.trigger.kind}`)
+        if (event.type === 'turn/start') manualOrder?.push('turn/start')
       })
       ctx.on('session/flush', (session) => {
         if (session === agent.session) manualOrder?.push('flush')
@@ -562,7 +562,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
         content: [{ type: 'text', text: 'Injected while compaction was running.' }],
         source: { kind: 'plugin', plugin: 'snapshot-injector' },
       }))
-      expect(inbox[0]).toMatch(/^enqueue:queued:/u)
+      expect(inbox[0]).toMatch(/^inserted:/u)
       expect(agent.status).toBe('idle')
       expect(agent.session.events.some(event => event.type === 'user/message'
         && event.data.source.kind === 'user'
@@ -575,7 +575,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
           agent.session.events.slice(-12).map(event => event.type).join(',')
         }`)
       await settleTerminal(terminal)
-      expect(inbox).toEqual([inbox[0], `dequeue:${inbox[0]?.slice('enqueue:queued:'.length) ?? ''}`])
+      expect(inbox).toEqual([inbox[0], `claimed:${inbox[0]?.slice('inserted:'.length) ?? ''}`])
     }
 
     const events: SessionEvent[] = [...agent.session.events]
@@ -641,7 +641,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
       const compactStartIndex = manualTimeline.indexOf('compact/start')
       const compactEndIndex = manualTimeline.indexOf('compact/end')
       const firstFlushIndex = manualTimeline.indexOf('flush')
-      const queuedTurnIndex = manualTimeline.indexOf('turn/start:message')
+      const queuedTurnIndex = manualTimeline.indexOf('turn/start')
       const commandDoneIndex = manualTimeline.indexOf('command/done')
       expect(manualTimeline.filter(item => item === 'command/run')).toHaveLength(1)
       expect(manualTimeline.filter(item => item === 'command/done')).toHaveLength(1)

+ 1 - 1
apps/web/tests/seeded-history.e2e.ts

@@ -86,7 +86,7 @@ function withCompaction(raw: string): string {
     lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
     return taken
   }
-  at({ type: 'turn/start', data: { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } })
+  at({ type: 'turn/start', data: { turn } })
   const startSeq = at({ type: 'compact/start', data: { turn } })
   const summarySeq = at({
     type: 'compact/summary',

+ 11 - 11
docs/cordis-catalog/events.md

@@ -460,7 +460,7 @@ Goal mutation accepted by one live agent. The matching `goal/change` session eve
 
 Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md)
 
-Source: [`packages/goal/goal/src/domain.ts:135`](../../packages/goal/goal/src/domain.ts)
+Source: [`packages/goal/goal/src/domain.ts:141`](../../packages/goal/goal/src/domain.ts)
 
 ## `llm/*`
 
@@ -481,7 +481,7 @@ The provider topology changed: an adapter registered or unregistered routes, or
 'llm/adapters-updated'(): void
 ```
 
-Source: [`packages/llm/llm/src/index.ts:68`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:69`](../../packages/llm/llm/src/index.ts)
 
 ### `llm/stream` — waterfall
 
@@ -505,7 +505,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
 
 Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/llm/llm/src/index.ts:57`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:58`](../../packages/llm/llm/src/index.ts)
 
 ## `session/*`
 
@@ -748,11 +748,11 @@ Source: [`packages/subagent/subagent/src/index.ts:131`](../../packages/subagent/
 
 ### `system-prompt/assemble` — waterfall
 
-Expert waterfall over the assembled sections, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns.
+Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns.
 
 ```ts cordis-catalog
 /**
- * Expert waterfall over the assembled sections, tools, and variables.
+ * Expert waterfall over the assembled sections, contexts, tools, and variables.
  * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
  * receive only that scope's assemblies. The returned value is authoritative.
  * A supplied signal controls only this explicit assembly request and must not
@@ -831,7 +831,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
 'tools/change'(): void
 ```
 
-Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:167`](../../packages/core/tools/src/index.ts)
 
 ### `tools/code-dispatch-log` — waterfall
 
@@ -855,7 +855,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri
 
 Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:142`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts)
 
 ### `tools/execute` — waterfall
 
@@ -877,7 +877,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
 
 Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:117`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:124`](../../packages/core/tools/src/index.ts)
 
 ### `tools/post-execute` — waterfall
 
@@ -900,7 +900,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
 
 Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:129`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:136`](../../packages/core/tools/src/index.ts)
 
 ### `tools/pre-execute` — waterfall
 
@@ -921,7 +921,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
 
 Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts)
 
 ### `tools/result` — emit
 
@@ -940,7 +940,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
 
 Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:150`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:157`](../../packages/core/tools/src/index.ts)
 
 ## `workflow/*`
 

+ 58 - 19
docs/cordis-catalog/services.md

@@ -220,7 +220,7 @@ Source: [`packages/core/agent/src/index.ts:216`](../../packages/core/agent/src/i
 
 ## `ctx.approval` — `ApprovalService`
 
-Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through prompt-submission notices.
+Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through the cache-safe runtime-context snapshot and prompt-submission notices.
 
 ```ts cordis-catalog
 /**
@@ -253,7 +253,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined
 
 Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md)
 
-Source: [`packages/ui/user-approval/src/index.ts:217`](../../packages/ui/user-approval/src/index.ts)
+Source: [`packages/ui/user-approval/src/index.ts:210`](../../packages/ui/user-approval/src/index.ts)
 
 ## `ctx.bash` — `BashExecutor` (abstract seam)
 
@@ -328,7 +328,7 @@ Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-ba
 
 ## `ctx.clientModuleHost` — `ClientModuleHostService`
 
-The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot sweep reports it).
+The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
 
 ```ts cordis-catalog
 /**
@@ -368,7 +368,7 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
 onGraphChanged(listener: () => void): () => void
 ```
 
-Source: [`packages/client/modules/src/index.ts:143`](../../packages/client/modules/src/index.ts)
+Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts)
 
 ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
 
@@ -463,6 +463,26 @@ Abstract compaction service. Implementations own trigger policy, retention, and
  */
 abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger, signal: AbortSignal, ): Promise<CompactionResult | null>
 
+/**
+ * Explicitly compact useful history even below automatic pressure thresholds.
+ * Implementations reserve idle turn admission synchronously before any
+ * asynchronous work, select a useful range without writing on a no-op, then
+ * append a standalone `compact/start` before summarization. That durable
+ * marker is the compaction lock until one `compact/end` attempt. Later waking
+ * prompts remain accepted in FIFO order and start only after the optional
+ * durability checkpoint and admission release. Context injected while the
+ * summary runs may sit between the marker pair; only the selected span must
+ * remain stable.
+ *
+ * @param agent - idle agent whose durable history should be compacted.
+ * @param signal - command-owned cancellation forwarded to summarization.
+ * @returns the compaction result, or `null` when no safe useful range exists.
+ * @throws {@link ManualCompactionError} for expected busy, changed-span,
+ * summarization/shrink, commit-stage, or persistence failures, and the exact
+ * abort reason when cancelled. Failed attempts remain visible in the log.
+ */
+abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise<CompactionResult | null>
+
 /**
  * Forcibly compact a range of surface nodes into a single summary node.
  * `start` and `end` name an inclusive span by surface position, not numeric seq
@@ -486,7 +506,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext,
 
 Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionTrigger](../core-data-structures/compaction.md)
 
-Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts)
+Source: [`packages/compact/compact/src/index.ts:80`](../../packages/compact/compact/src/index.ts)
 
 ## `ctx.credentials` — `Credentials` (abstract seam)
 
@@ -735,7 +755,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef
 
 Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md)
 
-Source: [`packages/goal/goal/src/index.ts:197`](../../packages/goal/goal/src/index.ts)
+Source: [`packages/goal/goal/src/index.ts:181`](../../packages/goal/goal/src/index.ts)
 
 ## `ctx.httpServer` — `HttpServerService`
 
@@ -880,7 +900,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
 
 Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/llm/llm/src/index.ts:229`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:232`](../../packages/llm/llm/src/index.ts)
 
 ## `ctx.permission` — `PermissionService`
 
@@ -932,7 +952,7 @@ set(session: Session, name: string): void
 
 Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
 
-Source: [`packages/ui/permission/src/index.ts:144`](../../packages/ui/permission/src/index.ts)
+Source: [`packages/ui/permission/src/index.ts:159`](../../packages/ui/permission/src/index.ts)
 
 ## `ctx.planMode` — `PlanModeService`
 
@@ -968,7 +988,7 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/plan/plan-mode/src/index.ts:182`](../../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:183`](../../packages/plan/plan-mode/src/index.ts)
 
 ## `ctx.pty` — `PtyService`
 
@@ -1077,7 +1097,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts:131`](../../packages/sandbox/san
 
 ## `ctx.sandboxPolicy` — `SandboxPolicyService`
 
-The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode and fallback workspace root. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability.
+The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability.
 
 ```ts cordis-catalog
 /**
@@ -1101,7 +1121,7 @@ overrideOf(session: Session): SandboxMode | undefined
 
 Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxMode](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) · [Session](../core-data-structures/session.md)
 
-Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../../packages/sandbox/sandbox-policy/src/index.ts)
+Source: [`packages/sandbox/sandbox-policy/src/index.ts:91`](../../packages/sandbox/sandbox-policy/src/index.ts)
 
 ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
 
@@ -1650,7 +1670,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
 
 Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/index.ts:701`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:720`](../../packages/core/session/src/index.ts)
 
 ## `ctx.sessionTitle` — `SessionTitleService`
 
@@ -2007,6 +2027,14 @@ Registry service for the prompt inputs assembled before each model step.
  */
 section(section: PromptSection): () => void
 
+/**
+ * Register ordered dynamic context in the calling context's scope. Scoped
+ * entries shadow global entries with the same name.
+ * @param context - the context contribution to register.
+ * @returns the exact Cordis effect disposer.
+ */
+context(context: PromptContext): () => void
+
 /**
  * Register a tool-schema provider in the calling context's scope. Global and
  * matching scoped providers both contribute; returning the reserved
@@ -2036,9 +2064,9 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine
 async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
 ```
 
-Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md)
+Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md)
 
-Source: [`packages/core/system-prompt/src/index.ts:248`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:290`](../../packages/core/system-prompt/src/index.ts)
 
 ## `ctx.tasks` — `TaskService` (abstract seam)
 
@@ -2191,7 +2219,7 @@ estimateMessage(message: Message): number
 
 Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md)
 
-Source: [`packages/llm/token-meter/src/index.ts:82`](../../packages/llm/token-meter/src/index.ts)
+Source: [`packages/llm/token-meter/src/index.ts:85`](../../packages/llm/token-meter/src/index.ts)
 
 ## `ctx.toolResultPrune` — `ToolResultPruneService`
 
@@ -2311,7 +2339,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
 
 Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:704`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:711`](../../packages/core/tools/src/index.ts)
 
 ## `ctx.tui` — `TuiExtensionService` (abstract seam)
 
@@ -2334,7 +2362,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
 abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
 ```
 
-Source: [`packages/ui/tui/src/index.ts:241`](../../packages/ui/tui/src/index.ts)
+Source: [`packages/ui/tui/src/index.ts:242`](../../packages/ui/tui/src/index.ts)
 
 ## `ctx.typert` — `TypertRegistry`
 
@@ -2510,7 +2538,7 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c
  * original error and a non-directory rejects. Repeated calls for the same
  * canonical path return the existing entity without changing its title.
  * A newly created workspace is prepended to the durable registry order.
- * A different canonical path cannot create a duplicate display title.
+ * Different canonical paths may share a display title.
  * @param path - Existing directory to own, in any path spelling.
  * @param title - Display title used only when a new record is created.
  * @returns the existing or newly durable workspace.
@@ -2542,6 +2570,15 @@ list(): Workspace[]
  */
 delete(id: WorkspaceId): Promise<boolean>
 
+/**
+ * Archive one session durably. The session must exist (live or in session
+ * persistence); its workspace accounting — or lack of one — is irrelevant.
+ * An already archived id resolves without writing.
+ * @param sessionId - The session to archive.
+ * @returns resolution after durability.
+ */
+archiveSession(sessionId: SessionId): Promise<void>
+
 /**
  * Resolve by canonical directory path without creating or mutating a
  * workspace. A missing path rejects during `realpath`; an existing unowned
@@ -2552,7 +2589,9 @@ delete(id: WorkspaceId): Promise<boolean>
 async resolveByPath(path: string): Promise<Workspace | undefined>
 ```
 
-Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts)
+Types: [SessionId](../core-data-structures/core.md)
+
+Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts)
 
 ## Inherited `ctx` members (cordis core + loader/hmr/timer)
 

+ 1 - 1
examples/headless-agent/tests/subagent-inheritance.snapshot.ts

@@ -42,7 +42,7 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise<void> {
     { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } },
     { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
     { type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } },
-    { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } },
+    { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
   ]
   try {
     await ctx.sessionPersistence.create(meta)

+ 3 - 1
packages/acp/acp/tests/turns.spec.ts

@@ -172,7 +172,9 @@ describe('ACP prompt lifecycle', () => {
       content: [{ type: 'text', text: 'autonomous work' }],
       source: { kind: 'plugin', plugin: 'test' },
     }))
-    await vi.waitFor(() => { expect(agent.status).toBe('running') })
+    await vi.waitFor(() => {
+      expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
+    })
 
     await harness.client.cancel({ sessionId })
     await agent.whenIdle()

+ 12 - 17
packages/client/connection/src/client/fixture.ts

@@ -1477,23 +1477,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
       nextTurn.set(sessionId, turn + 1)
       retryScenarios.set(sessionId, { turn, stepStarted: true })
       setRunning(sessionId, true)
-      append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
+      append(sessionId, { type: 'turn/start', data: { turn } })
       append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } })
       append(sessionId, { type: 'step/start', data: { turn, step: 1 } })
       append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
       append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
-      append(sessionId, { type: 'step/end', data: { turn, step: 1 } })
     },
-    /** Record one retry decision, then open the next retry turn. */
+    /** Record one retry decision; the next attempt remains in the same step. */
     scheduleModelRetry(id: string, retry = 1, delayMs = 450): void {
       const sessionId = sid(id)
       const scenario = retryScenarios.get(sessionId)
       if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
       if (!scenario.stepStarted) {
-        append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
         append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
         append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } })
-        append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
         scenario.stepStarted = true
       }
       const failure = { code: 'TRANSPORT', message: '连接被重置' }
@@ -1505,14 +1502,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
           retry, maxRetries: 2, delayMs, failure,
         },
       })
-      append(sessionId, {
-        type: 'turn/end',
-        data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } },
-      })
-      const next = nextTurn.get(sessionId) ?? scenario.turn + 1
-      nextTurn.set(sessionId, next + 1)
-      append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } })
-      scenario.turn = next
       scenario.stepStarted = false
     },
     /** Record one retry decision, then cancel its source turn before the retry starts. */
@@ -1529,17 +1518,23 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
           retry: 1, maxRetries: 2, delayMs, failure,
         },
       })
-      append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } })
+      append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
+      append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
+      } })
       retryScenarios.delete(sessionId)
       setRunning(sessionId, false)
     },
-    /** Finish the timing-hook retry with a finalized response in the open retry turn. */
+    /** Finish the timing-hook retry with a finalized response in the open step. */
     completeModelRetry(id: string): void {
       const sessionId = sid(id)
       const scenario = retryScenarios.get(sessionId)
       if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
       retryScenarios.delete(sessionId)
-      append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
+      append(sessionId, { type: 'assistant/chunk', data: {
+        turn: scenario.turn,
+        step: 1,
+        chunk: { type: 'block-start', index: 0, blockType: 'text' },
+      } })
       append(sessionId, {
         type: 'assistant/message',
         surfaceOp: 'append',
@@ -1550,7 +1545,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
         },
       })
       append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
-      append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } })
+      append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, step: 1, reason: { kind: 'completed' } } })
       setRunning(sessionId, false)
     },
     /** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */

+ 3 - 3
packages/client/runtime/src/client/sessions/conversation.ts

@@ -122,15 +122,15 @@ export interface ContextMessageNode {
   source: unknown
 }
 
-/** Durable notice that a closed failed step is waiting for a model-request retry. */
+/** Durable notice that a failed model request is waiting for another attempt. */
 export type ModelRetryNode = LlmRetryEventData & {
   kind: 'model-retry'
   seq: number
   /** Unix epoch ms from the llm/retry session event. */
   time: number
   /**
-   * Client-derived lifecycle: scheduled until a retry turn starts, started
-   * once it does, or cancelled when the failed turn aborts first.
+   * Client-derived lifecycle: scheduled until another attempt emits retry or
+   * chunk evidence, started once it does, or cancelled if the turn aborts first.
    */
   retryState: 'scheduled' | 'started' | 'cancelled'
 }

+ 6 - 3
packages/client/runtime/src/client/sessions/failure-display.ts

@@ -1,10 +1,13 @@
 /**
  * Convert a durable failure into copy that is safe to expose in the GUI.
- * @param failure - Structured failure preserved by the session event.
+ * @param failure - Failure value preserved by the session event.
  * @returns Display-safe copy for client projections.
  */
-export function displayFailureMessage(failure: { code?: string; message: string }): string {
+export function displayFailureMessage(failure: unknown): string {
+  if (failure === null || typeof failure !== 'object') return String(failure)
+  const record = failure as { code?: unknown; message?: unknown }
   // Provider AUTH messages may echo a masked or partially preserved credential.
   // Keep the raw diagnostic in the session log, but never project it into UI state.
-  return failure.code === 'AUTH' ? 'API key is invalid' : failure.message
+  if (record.code === 'AUTH') return 'API key is invalid'
+  return typeof record.message === 'string' ? record.message : JSON.stringify(failure)
 }

+ 2 - 2
packages/client/runtime/src/client/sessions/request-inspection.ts

@@ -360,9 +360,9 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
     }
     if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
       const reason = sourceEvent.data.reason
-      updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
+      updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, sourceEvent.data.step)), {
         status: 'error',
-        error: displayFailureMessage('failure' in reason ? reason.failure : reason),
+        error: displayFailureMessage(reason.error),
       })
       continue
     }

+ 15 - 8
packages/client/runtime/src/client/sessions/session.ts

@@ -646,6 +646,7 @@ export class Session implements SessionFace {
       if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
         this.partial = null
       }
+      this.settleScheduledRetry('started', data.turn)
       this.derivedNodes.push({
         kind: 'model-retry',
         seq: event.seq,
@@ -716,11 +717,10 @@ export class Session implements SessionFace {
       return
     }
     switch (event.type) {
-      case 'turn/start': {
-        if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
+      case 'turn/start':
         return
-      }
       case 'assistant/chunk': {
+        this.settleScheduledRetry('started', event.data.turn)
         const { turn, step, chunk } = event.data
         if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
           this.partial = new PartialAccumulator(turn, step)
@@ -748,22 +748,29 @@ export class Session implements SessionFace {
         return
       }
       case 'turn/end': {
-        if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
+        if (event.data.reason.kind === 'error') {
+          this.settleScheduledRetry('started', event.data.turn)
+        } else if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'interrupted') {
           this.settleScheduledRetry('cancelled', event.data.turn)
         }
         if (
           event.data.reason.kind === 'error'
           && !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
         ) {
-          const failure = 'failure' in event.data.reason ? event.data.reason.failure : event.data.reason
+          const failure = event.data.reason.error
+          const failedTurn = event.data.turn
+          const code = failure !== null && typeof failure === 'object'
+            && typeof (failure as { code?: unknown }).code === 'string'
+            ? (failure as { code: string }).code
+            : undefined
           this.derivedNodes.push({
             kind: 'turn-error',
             seq: event.seq,
             time: event.time,
-            turn: event.data.turn,
-            step: event.data.reason.step,
+            turn: failedTurn,
+            step: event.data.step,
             message: displayFailureMessage(failure),
-            ...(failure.code === undefined ? {} : { code: failure.code }),
+            ...code === undefined ? {} : { code },
           })
           this.derivedRev++
         }

+ 6 - 1
packages/client/runtime/tests/event-script.ts

@@ -82,7 +82,12 @@ export const ev = {
       },
     }),
   turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
-    at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
+    at(seq, { type: 'turn/end', data: {
+      turn,
+      reason: reason === 'completed'
+        ? { kind: 'completed' }
+        : { kind: 'aborted', reason: { kind: reason === 'disposed' ? 'disposed' : 'user' } },
+    } }),
   commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
     at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
   commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>

+ 5 - 10
packages/client/runtime/tests/request-inspection.spec.ts

@@ -225,20 +225,15 @@ describe('inspectRequests', () => {
     const snapshot = inspectRequests(entriesOf([
       at(0, 'step/start', { turn: 1, step: 1 }),
       at(1, 'turn/end', {
-        turn: 1,
-        reason: {
-          kind: 'error',
-          step: 1,
-          failure: {
-            code: 'AUTH',
-            message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
-          },
+        turn: 1, step: 1, reason: { kind: 'error', error: {
+          code: 'AUTH',
+          message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
+        },
         },
       }),
       at(2, 'step/start', { turn: 2, step: 1 }),
       at(3, 'turn/end', {
-        turn: 2,
-        reason: { kind: 'error', step: 1, message: 'plugin exploded' },
+        turn: 2, step: 1, reason: { kind: 'error', error: 'plugin exploded' },
       }),
     ]))
 

+ 41 - 38
packages/client/runtime/tests/session.spec.ts

@@ -162,7 +162,7 @@ describe('live event path', () => {
     expect((last as { interrupted?: true }).interrupted).toBeUndefined()
   })
 
-  it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
+  it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => {
     const { session } = await opened()
     const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
     const retryTurn = [
@@ -171,25 +171,13 @@ describe('live event path', () => {
       ev.stepStart(8, 1),
       ev.chunkStart(9, 1),
       ev.chunkText(10, 1, '不完整回复'),
-      ev.stepEnd(11, 1),
-      ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'),
-      at(13, {
-        type: 'turn/end',
-        data: {
-          turn: 1,
-          reason: {
-            kind: 'error', step: 0,
-            failure: { code: 'TRANSPORT', message: '连接被重置' },
-          },
-        },
-      }),
-      at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }),
-      ev.stepStart(15, 2),
-      ev.assistant(16, 2, '完整回复'),
-      ev.stepEnd(17, 2),
-      ev.turnEnd(18, 2),
+      ev.retry(11, 1, 0, 1, 2, 450, '连接被重置'),
+      ev.chunkStart(12, 1),
+      ev.assistant(13, 1, '完整回复'),
+      ev.stepEnd(14, 1),
+      ev.turnEnd(15, 1),
     ]
-    for (const event of retryTurn.slice(0, 7)) feed(event)
+    for (const event of retryTurn.slice(0, 6)) feed(event)
 
     let snapshot = session.getSnapshot()
     expect(snapshot.partial).toBeNull()
@@ -208,7 +196,7 @@ describe('live event path', () => {
     })
     expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
 
-    for (const event of retryTurn.slice(7)) feed(event)
+    for (const event of retryTurn.slice(6)) feed(event)
     snapshot = session.getSnapshot()
     expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
     expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
@@ -230,33 +218,30 @@ describe('live event path', () => {
     const failedTurns = [
       ev.turnStart(6, 1),
       ev.user(7, '鉴权失败'),
-      at(8, {
+      ev.stepStart(8, 1),
+      at(9, {
         type: 'turn/end',
-        data: {
-          turn: 1,
-          reason: {
-            kind: 'error',
-            step: 0,
-            failure: {
-              code: 'AUTH',
-              message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
-            },
-          },
+        data: { turn: 1, step: 0, reason: { kind: 'error', error: {
+          code: 'AUTH',
+          message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
+        },
+        },
         },
       }),
-      ev.turnStart(9, 2),
-      ev.user(10, '内部失败'),
-      at(11, {
+      ev.turnStart(10, 2),
+      ev.user(11, '内部失败'),
+      ev.stepStart(12, 2, 1),
+      at(13, {
         type: 'turn/end',
-        data: { turn: 2, reason: { kind: 'error', step: 1, message: 'plugin exploded' } },
+        data: { turn: 2, step: 1, reason: { kind: 'error', error: 'plugin exploded' } },
       }),
     ]
     for (const event of failedTurns) feed(event)
 
     const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error')
     expect(errors).toMatchObject([
-      { seq: 8, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
-      { seq: 11, turn: 2, step: 1, message: 'plugin exploded' },
+      { seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
+      { seq: 13, turn: 2, step: 1, message: 'plugin exploded' },
     ])
     expect('code' in errors[1]!).toBe(false)
 
@@ -395,7 +380,7 @@ describe('live event path', () => {
   })
 
   it.each(['aborted', 'disposed'] as const)(
-    'marks a scheduled retry as cancelled when its failed turn ends %s',
+    'marks a scheduled retry as cancelled when its failed turn receives the %s cause',
     async (reason) => {
       const { session } = await opened()
       const feed = (event: SessionEvent) => {
@@ -415,6 +400,24 @@ describe('live event path', () => {
     },
   )
 
+  it('marks a scheduled retry as started when its failed turn ends with an error', async () => {
+    const { session } = await opened()
+    const feed = (event: SessionEvent) => {
+      session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
+    }
+    feed(ev.turnStart(6, 1))
+    feed(ev.retry(7, 1))
+    feed(at(8, {
+      type: 'turn/end',
+      data: { turn: 1, step: 0, reason: { kind: 'error', error: 'retry failed' } },
+    }))
+
+    expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
+      kind: 'model-retry',
+      retryState: 'started',
+    })
+  })
+
   it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
     const { session } = await opened()
     const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }

+ 1 - 1
packages/compact/compact-basic/src/index.ts

@@ -11,7 +11,7 @@ import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compa
 import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
 import type { Session } from '@deepseek-ai/dsh-session'
 import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
-import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm'
+import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
 import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
 // Type-only: makes the optional sibling service available to `ctx.get()`.
 import type {} from '@deepseek-ai/dsh-compact-tool-result-prune'

+ 4 - 4
packages/compact/compact-basic/tests/compact-basic.spec.ts

@@ -130,7 +130,7 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
       }),
     }, { surfaceOp: 'append' })
     session.append('step/end', { turn, step: 1 })
-    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
   }
   session.append('turn/start', {
     turn: turns + 1,
@@ -180,7 +180,7 @@ function toolConversation(): Session {
       }),
     }, { surfaceOp: 'append' })
     session.append('step/end', { turn, step: 1 })
-    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
   }
   session.append('turn/start', { turn: 4 })
   return session
@@ -226,7 +226,7 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess
     meta: { presentation: 'preserved' },
   }, { surfaceOp: 'append' })
   session.append('step/end', { turn: 1, step: 1 })
-  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
   session.append('turn/start', { turn: 2 })
   return session
 }
@@ -935,7 +935,7 @@ describe('compaction region transaction', () => {
   it('requires an open turn and an idle compaction bracket', async () => {
     const compact = service()
     const closed = conversation(1)
-    closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
+    closed.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
     const nodes = closed.surface.nodes
     await expect(compact.compactRegion(
       nodes[0]!,

+ 1 - 1
packages/compact/compact-basic/tests/compact-loop-repro.spec.ts

@@ -209,7 +209,7 @@ function overflowHistorySeed(): SessionEvent[] {
       }),
     }, { surfaceOp: 'append' })
     session.append('step/end', { turn, step: 1 })
-    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
   }
   return [...session.events]
 }

+ 1 - 1
packages/compact/compact-basic/tests/loader-composition.spec.ts

@@ -77,7 +77,7 @@ describe('real Loader composition', () => {
     expect(unloaded).toEqual([])
     expect(loaded.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
     expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
-    expect((loaded.compact as BasicCompactService).config).toMatchObject({
+    expect((loaded.compact as unknown as BasicCompactService).config).toMatchObject({
       thresholdRatio: 0.5,
       retainRatio: 0.125,
       auto: false,

+ 8 - 8
packages/compact/compact-basic/tests/manual-compact.spec.ts

@@ -112,7 +112,7 @@ async function loopHarness(): Promise<LoopHarness> {
   const agent = ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL })
   const log: string[] = []
   ctx.on('session/event', (_session, event) => {
-    if (event.type === 'turn/start') log.push(`turn/start:${event.data.trigger.kind}`)
+    if (event.type === 'turn/start') log.push('turn/start')
     if (event.type === 'turn/end') log.push('turn/end')
     if (event.type === 'compact/start') log.push(`compact/start:${String(event.data.turn)}`)
     if (event.type === 'compact/summary') log.push('compact/summary')
@@ -169,7 +169,7 @@ function closedConversation(turns = 2, lastTurnNumber = turns): Session {
   const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`))
   for (let index = 1; index <= turns; index += 1) {
     const turn = index === turns ? lastTurnNumber : index
-    session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
+    session.append('turn/start', { turn })
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: `${PROMPT} ${turn}` }],
       source: { kind: 'user' },
@@ -190,7 +190,7 @@ function closedConversation(turns = 2, lastTurnNumber = turns): Session {
       }),
     }, { surfaceOp: 'append' })
     session.append('step/end', { turn, step: 1 })
-    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
   }
   return session
 }
@@ -256,7 +256,7 @@ describe('compactNow through the real loop', () => {
     const summary = log.indexOf('compact/summary')
     const end = log.indexOf('compact/end:null')
     const flush = log.indexOf('flush')
-    const nextTurn = log.indexOf('turn/start:message')
+    const nextTurn = log.indexOf('turn/start')
     expect(start).toBeLessThan(summary)
     expect(summary).toBeLessThan(end)
     expect(end).toBeLessThan(flush)
@@ -424,8 +424,8 @@ describe('compactNow transaction and failure classification', () => {
     const { compact } = detachedService()
     const original = closedConversation(2)
     original.append('compact/start', { turn: null })
-    original.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
-    original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
+    original.append('turn/start', { turn: 3 })
+    original.append('turn/end', { turn: 3, step: 0, reason: { kind: 'interrupted' } })
     const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
     const agent = fakeAgent(reloaded, () => () => undefined)
 
@@ -436,7 +436,7 @@ describe('compactNow transaction and failure classification', () => {
   it('refuses an open turn in the log', async () => {
     const { compact } = detachedService()
     const session = closedConversation(2)
-    session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
+    session.append('turn/start', { turn: 3 })
     const agent = fakeAgent(session, () => () => undefined)
 
     const error = await rejection(compact.compactNow(agent, SIGNAL))
@@ -814,7 +814,7 @@ describe('compactNow transaction and failure classification', () => {
   it('excludes a manual request while an explicit region compaction runs', async () => {
     const { compact } = detachedService()
     const session = closedConversation(3)
-    session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
+    session.append('turn/start', { turn: 4 })
     const agent = fakeAgent(session, () => () => undefined)
     const gate = deferred()
     compact.gate = gate.promise

+ 1 - 1
packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts

@@ -60,7 +60,7 @@ function appendToolStep(
     ...extra,
   }, { surfaceOp: 'append' })
   session.append('step/end', { turn, step: 1 })
-  session.append('turn/end', { turn, reason: { kind: 'completed' } })
+  session.append('turn/end', { turn, step: 1, reason: { kind: 'completed' } })
   return result.seq
 }
 

+ 9 - 9
packages/compact/compact/tests/invariant.spec.ts

@@ -90,7 +90,7 @@ describe('compaction invariants', () => {
 
     expect(() => replayed.append(
       'turn/end',
-      { turn: 1, reason: { kind: 'interrupted' } },
+      { turn: 1, step: 0, reason: { kind: 'interrupted' } },
     )).not.toThrow()
   })
 
@@ -100,7 +100,7 @@ describe('compaction invariants', () => {
     const source = new Session(SessionId('stale-repaired-compaction-source'))
     source.append('compact/start', { turn: null })
     startTurn(source)
-    source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
+    source.append('turn/end', { turn: 1, step: 0, reason: { kind: 'interrupted' } })
     const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), {
       seed: source.events,
     })
@@ -116,7 +116,7 @@ describe('compaction invariants', () => {
 
     expect(() => {
       startTurn(replayed, 2)
-      replayed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
+      replayed.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
     }).not.toThrow()
   })
 
@@ -126,7 +126,7 @@ describe('compaction invariants', () => {
     const source = new Session(SessionId('closed-nested-compaction-source'))
     source.append('compact/start', { turn: null })
     startTurn(source)
-    source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
+    source.append('turn/end', { turn: 1, step: 0, reason: { kind: 'interrupted' } })
     source.append('compact/end', { turn: null, error: 'failed after crossing turn' })
     const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), {
       seed: source.events,
@@ -147,7 +147,7 @@ describe('compaction invariants', () => {
     await ctx.plugin(InvariantService)
     await ctx.plugin(CompactInvariant)
     expect(() => session.append('compact/end', { turn: 1, error: 'resume failed' })).not.toThrow()
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
   })
 
   it('adopts a bare session and ignores unrelated committed events', async () => {
@@ -200,7 +200,7 @@ describe('compaction invariants', () => {
     await ctx.plugin(SessionStore)
     const session = ctx.sessions.create()
     startTurn(session)
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     session.append('compact/start', { turn: 1 })
     await ctx.plugin(InvariantService)
     await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
@@ -215,7 +215,7 @@ describe('compaction invariants', () => {
     standalone.append('compact/end', { turn: null, error: 'cancelled' })
     expect(() => {
       startTurn(standalone)
-      standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      standalone.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     }).not.toThrow()
 
     const numbered = ctx.sessions.create()
@@ -223,12 +223,12 @@ describe('compaction invariants', () => {
     numbered.append('compact/start', { turn: 1 })
     expect(() => numbered.append(
       'turn/end',
-      { turn: 1, reason: { kind: 'completed' } },
+      { turn: 1, step: 0, reason: { kind: 'completed' } },
     )).toThrow(/turn\/end cannot cross an open compaction for turn 1/)
     numbered.append('compact/end', { turn: 1, error: 'cancelled' })
     expect(() => numbered.append(
       'turn/end',
-      { turn: 1, reason: { kind: 'completed' } },
+      { turn: 1, step: 0, reason: { kind: 'completed' } },
     )).not.toThrow()
   })
 

+ 1 - 2
packages/compact/compact/tests/tool-pairing.spec.ts

@@ -271,8 +271,7 @@ describe('tool-pairing cache refresh', () => {
     expect(eventIndexReads).toBe(3)
 
     events.push({
-      type: 'turn/end', seq: 3, time: 3,
-      data: { turn: 1, reason: { kind: 'completed' } },
+      type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' } },
     })
     expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
     expect(eventCollectionReads).toBe(1)

+ 2 - 2
packages/context/time-context/tests/invariant.spec.ts

@@ -47,7 +47,7 @@ function preparing(turn: number, step: number): Session {
   const session = new Session(SessionId(`time-invariant-${turn}-${step}`))
   for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
     session.append('turn/start', { turn: priorTurn })
-    session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: priorTurn, step: 0, reason: { kind: 'completed' } })
   }
   session.append('turn/start', { turn })
   session.append('user/message', createUserMessage({
@@ -127,7 +127,7 @@ describe('time-context invariants', () => {
   it('rejects a reading after cancellation closes the turn', async () => {
     const ctx = await setup()
     const session = preparing(1, 2)
-    session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
+    session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
     expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) })
       .toThrow(/at a prompt boundary/)
   })

+ 2 - 5
packages/context/time-context/tests/time-context.spec.ts

@@ -42,14 +42,11 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
     session,
     inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
     status: 'running',
-    acceptsNextStep: true,
     ctx: new Context(),
     send: () => {},
     followup: () => {},
     steer: () => {},
     inject: () => { throw new Error('time-context must append directly to the open step') },
-    updateInbox: () => 'not-found',
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }
@@ -250,7 +247,7 @@ describe('durable step context', () => {
       surfaceOp: { op: 'replace', start: user.seq, end: reading.seq },
       sourceEventSeqs: [user.seq, reading.seq],
     })
-    original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    original.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
 
     const resumed = new Session(SessionId('resumed'), [...original.events])
@@ -279,7 +276,7 @@ describe('durable step context', () => {
     const firstAgent = sessionAgent(first, 'first-agent')
     openMessageTurn(first, 1)
     await fire(ctx, firstAgent, 1, 1)
-    first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    first.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
 
     vi.setSystemTime(BASE + 500)
     openMessageTurn(first, 2)

+ 3 - 6
packages/context/tmux-context/tests/tmux-context.spec.ts

@@ -98,14 +98,11 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
     session,
     inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
     status: 'running',
-    acceptsNextStep: true,
     ctx: new Context(),
     send: () => {},
     followup: () => {},
     steer: () => {},
     inject: () => { throw new Error('tmux-context must append directly to the open step') },
-    updateInbox: () => 'not-found',
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }
@@ -212,12 +209,12 @@ describe('tmux-context injection', () => {
 
     openMessageTurn(session, 1)
     await fire(ctx, agent, 1, 1)
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
 
     // Same state on turn 2: suppressed.
     openMessageTurn(session, 2)
     await fire(ctx, agent, 2, 1)
-    session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
     expect(contextTexts(session)).toHaveLength(1)
 
     // Moved pane on turn 3: re-injected.
@@ -240,7 +237,7 @@ describe('tmux-context injection', () => {
 
     openMessageTurn(session, 1)
     await fire(ctx, agent, 1, 1)
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
 
     // Changed state but inside the interval: suppressed, and never queried.
     bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`)

+ 3 - 11
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -922,7 +922,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'context(context: PromptContext): () => void',
-        jsDoc: '/**\n * Register ordered cache-safe dynamic context in the calling context\'s scope.\n * A scoped context shadows a global context with the same name; duplicates\n * within one layer and non-finite orders throw. Registration and disposal\n * emit `system-prompt/change`.\n * @param context - the context contribution to register.\n * @returns the exact Cordis effect disposer.\n */',
+        jsDoc: '/**\n * Register ordered dynamic context in the calling context\'s scope. Scoped\n * entries shadow global entries with the same name.\n * @param context - the context contribution to register.\n * @returns the exact Cordis effect disposer.\n */',
       },
       {
         signature: 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void',
@@ -1537,7 +1537,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'Agent',
-    declaration: 'export interface Agent {\n    readonly id: SessionId;\n    readonly options: AgentOptions;\n    readonly session: Session;\n    readonly inbox: Inbox;\n    readonly status: AgentStatus;\n    readonly acceptsNextStep: boolean;\n    readonly ctx: Context;\n    send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n    send(message: UserMessage, options: SendOptions): void;\n    reserveTurnAdmission(): (() => void) | undefined;\n    updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n    cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n    whenIdle(): Promise<void>;\n    followup(message: UserMessage): void;\n    steer(message: UserMessage): void;\n    inject(message: UserMessage): void;\n}',
+    declaration: 'export interface Agent {\n    readonly id: SessionId;\n    readonly options: AgentOptions;\n    readonly session: Session;\n    readonly inbox: Inbox;\n    readonly status: AgentStatus;\n    readonly ctx: Context;\n    cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n    whenIdle(): Promise<void>;\n    send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n    followup(message: UserMessage): void;\n    steer(message: UserMessage): void;\n    inject(message: UserMessage): void;\n}',
   },
   {
     name: 'AgentCancelCause',
@@ -2303,14 +2303,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SearchResultView',
     declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;',
   },
-  {
-    name: 'SendOptions',
-    declaration: 'export interface SendOptions {\n    target: SendTarget;\n    wakeup: boolean;\n}',
-  },
-  {
-    name: 'SendTarget',
-    declaration: 'export type SendTarget = \'next-turn\' | \'next-step\';',
-  },
   {
     name: 'Session',
     declaration: 'export class Session {\n    get surface(): SessionSurface;\n    readonly header: SessionHeader;\n    get id(): SessionId;\n    readonly firstLiveSeq: number;\n    constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n    get events(): readonly SessionEvent[];\n    get seq(): number;\n    append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n        opts: SurfaceIntent\n    ] : [\n    ]): SessionEvent<T>;\n    requestHeader(): EpochHeader | undefined;\n    requestContext(): RequestContext | undefined;\n    deriveMessages(): Message[];\n    deriveEventMessage(event: SessionEvent): Message | null;\n}',
@@ -2325,7 +2317,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SessionEventMap',
-    declaration: 'export interface SessionEventMap {\n    \'turn/start\': {\n        turn: number;\n    };\n    \'turn/end\': {\n        turn: number;\n        reason: TurnEndReason;\n    };\n    \'step/start\': {\n        turn: number;\n        step: number;\n    };\n    \'step/end\': {\n        turn: number;\n        step: number;\n    };\n    \'user/message\': UserMessage;\n    \'assistant/chunk\': {\n        turn: number;\n        step: number;\n        chunk: StreamChunk;\n    };\n    \'assistant/message\': {\n        turn: number;\n        step: number;\n        message: AssistantMessage;\n        usage?: TokenUsage;\n    };\n    \'tool/call\': {\n        turn: number;\n        step: number;\n        callId: CallId;\n        name: string;\n        arguments: string;\n    };\n    \'tool/result\': {\n        turn: number;\n        step: number;\n        message: ToolResultMessage;\n        error?: {\n            name: string;\n            code: string;\n        };\n        meta?: JsonValue;\n    };\n    \'steering/message\': {\n        turn: number;\n        message: UserMessage;\n    };\n    \'todo/write\': {\n        todos: TodoItem[];\n    };\n    \'request/header\': {\n        header: EpochHeader;\n        reason: RequestHeaderReason;\n    };\n    \'request/context\': RequestContext;\n    \'session/end-seed\': Record<string, never>;\n}',
+    declaration: 'export interface SessionEventMap {\n    \'turn/start\': {\n        turn: number;\n    };\n    \'turn/end\': {\n        turn: number;\n        step: number;\n        reason: TurnEndReason;\n    };\n    \'step/start\': {\n        turn: number;\n        step: number;\n    };\n    \'step/end\': {\n        turn: number;\n        step: number;\n    };\n    \'user/message\': UserMessage;\n    \'assistant/chunk\': {\n        turn: number;\n        step: number;\n        chunk: StreamChunk;\n    };\n    \'assistant/message\': {\n        turn: number;\n        step: number;\n        message: AssistantMessage;\n        usage?: TokenUsage;\n    };\n    \'tool/call\': {\n        turn: number;\n        step: number;\n        callId: CallId;\n        name: string;\n        arguments: string;\n    };\n    \'tool/result\': {\n        turn: number;\n        step: number;\n        message: ToolResultMessage;\n        error?: {\n            name: string;\n            code: string;\n        };\n        meta?: JsonValue;\n    };\n    \'steering/message\': {\n        turn: number;\n        message: UserMessage;\n    };\n    \'todo/write\': {\n        todos: TodoItem[];\n    };\n    \'request/header\': {\n        header: EpochHeader;\n        reason: RequestHeaderReason;\n    };\n    \'request/context\': RequestContext;\n    \'session/end-seed\': Record<string, never>;\n}',
   },
   {
     name: 'SessionEventMetadataFilter',

+ 4 - 4
packages/core/agent-loop/src/agent.ts

@@ -212,8 +212,9 @@ export class ReactLoopAgent implements Agent {
     try {
       while (true) {
         signal.throwIfAborted()
-        const step = ++phase.step
+        const step = phase.step + 1
         this.session.append('step/start', { turn, step })
+        phase.step = step
         try {
           for (const message of decision.messages) {
             this.session.append('user/message', message, { surfaceOp: 'append' })
@@ -249,7 +250,7 @@ export class ReactLoopAgent implements Agent {
     } finally {
       try {
         // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending
-        this.session.append('turn/end', { turn, reason: turnEnds! })
+        this.session.append('turn/end', { turn, step: phase.step, reason: turnEnds! })
       } catch (error: unknown) {
         this.throwError(error)
       }
@@ -262,11 +263,10 @@ export class ReactLoopAgent implements Agent {
     const { turn, step, abort: { signal } } = this.phase
     signal.throwIfAborted()
     const system = renderPrompt(assembly)
-    const boundaryMessages = this.session.deriveMessages()
 
     while (true) {
       const { request, preparedCall } = await this.buildRequest(
-        turn, step, assembly.tools, system, boundaryMessages, signal,
+        turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
       )
       const assembler = new BlockAssembler()
       const chunkSeqs: number[] = []

+ 3 - 3
packages/core/agent-loop/tests/contract-regressions.spec.ts

@@ -385,7 +385,7 @@ describe('plugin exceptions are contained', () => {
     send(agent, 'first')
     await waitForIdle(ctx, agent)
     expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
-      data: { reason: { kind: 'error', error: 'broken continuation plugin' } },
+      data: { step: 1, reason: { kind: 'error', error: 'broken continuation plugin' } },
     })
 
     // the loop is still alive: a second send works normally
@@ -629,7 +629,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
 
     const events = [...agent.session.events]
     const turnEnd = events.find(event => event.type === 'turn/end')
-    expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', error: failure })
+    expect(turnEnd).toMatchObject({ data: { step: 1, reason: { kind: 'error', error: failure } } })
     // A failed step must not synthesize an assistant message.
     expect(events.some(event => event.type === 'assistant/message')).toBe(false)
   })
@@ -808,7 +808,7 @@ describe('turn and step boundary recovery', () => {
       errors: 1,
     })
     expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
-      data: { reason: { kind: 'error', error: 'reject step-start before commit' } },
+      data: { step: 0, reason: { kind: 'error', error: 'reject step-start before commit' } },
     })
   })
 

+ 1 - 0
packages/core/agent-loop/tests/request-reconstruction.spec.ts

@@ -360,6 +360,7 @@ describe('request stability across the loop', () => {
 
       expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
         data: {
+          step: 1,
           reason: {
             kind: 'error',
             error: failure instanceof LlmError ? failure.failure : failure.message,

+ 4 - 4
packages/core/agent-loop/tests/resume.spec.ts

@@ -44,7 +44,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
   // the model merely to construct this lifecycle fixture.
   const seed: SessionEvent[] = [
     { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
-    { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
+    { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
   ]
   const session = ctx.sessions.create(sessionId, { seed })
   await ctx.sessions.flush(session)
@@ -108,7 +108,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
         surfaceOp: 'append',
       },
       { type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
-      { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
     ] as unknown as SessionEvent[])
     await first.ctx.fiber.dispose()
 
@@ -181,7 +181,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
       .rejects.toThrow(/live turn is open/)
 
-    first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    first.session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     await ctx.sessions.flush(first.session)
     const loaded = await ctx.sessionPersistence.load(sessionId)
     expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
@@ -496,7 +496,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     // materializes the fork (header + seed) on disk.
     const seed: SessionEvent[] = [
       { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
-      { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
     ]
     const adapter1 = new MockAdapter([textResponse('a')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)

+ 1 - 1
packages/core/agent-loop/tests/tool-calls.spec.ts

@@ -683,7 +683,7 @@ describe('tool-call scheduler: failure quiescence', () => {
     expect(turnEndBeforeDrain).toBeUndefined()
     expect(gated.pending()).toEqual([])
     expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
-      data: { reason: { kind: 'error', error: schedulerError.message } },
+      data: { step: 1, reason: { kind: 'error', error: schedulerError.message } },
     })
   })
 })

+ 0 - 1
packages/core/agent/tests/agent.spec.ts

@@ -29,7 +29,6 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
     followup: () => {},
     steer: () => {},
     inject: () => {},
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }

+ 4 - 0
packages/core/session/src/invariant.ts

@@ -87,6 +87,10 @@ function validateEvent(
       if (trace.openStep !== null) {
         fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
       }
+      const lastStep = trace.nextStep - 1
+      if (event.data.step !== lastStep) {
+        fail(`turn/end ${event.data.turn} expected last step ${lastStep}, got ${event.data.step}`)
+      }
       openTurn = null
       nextTurn += 1
       break

+ 5 - 1
packages/core/session/src/repair.ts

@@ -46,6 +46,7 @@ export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN'
 export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
   let openTurn: number | null = null
   let openStep: number | null = null
+  let lastStep = 0
   // Reset at each turn boundary so earlier calls cannot leak into tail repair.
   // Assistant blocks register calls; later tool/call events add provenance seqs.
   const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
@@ -54,15 +55,18 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
       case 'turn/start':
         openTurn = event.data.turn
         openStep = null
+        lastStep = 0
         pendingCalls.clear()
         break
       case 'turn/end':
         openTurn = null
         openStep = null
+        lastStep = 0
         pendingCalls.clear()
         break
       case 'step/start':
         openStep = event.data.step
+        lastStep = event.data.step
         break
       case 'step/end':
         pendingCalls.clear()
@@ -147,6 +151,6 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
   if (openStep !== null) {
     closers.push({ type: 'step/end', seq: seq++, time, data: { turn: openTurn, step: openStep } })
   }
-  closers.push({ type: 'turn/end', seq: seq++, time, data: { turn: openTurn, reason: { kind: 'interrupted' } } })
+  closers.push({ type: 'turn/end', seq: seq++, time, data: { turn: openTurn, step: lastStep, reason: { kind: 'interrupted' } } })
   return closers
 }

+ 6 - 5
packages/core/session/src/types.ts

@@ -183,12 +183,13 @@ export interface SessionEventMap {
    */
   'turn/start': { turn: number }
   /**
-   * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
-   * awaits `session/flush` after an ordinary turn ends before claiming the next
-   * queued item. Success commits the turn; rejection is reported live and does
-   * not prevent later work.
+   * Closes turn `turn` after `step`, the last entered step (`0` when none),
+   * with the {@link TurnEndReason} that ended it. The loop awaits
+   * `session/flush` after an ordinary turn ends before claiming the next queued
+   * item. Success commits the turn; rejection is reported live and does not
+   * prevent later work.
    */
-  'turn/end': { turn: number; reason: TurnEndReason }
+  'turn/end': { turn: number; step: number; reason: TurnEndReason }
   /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
   'step/start': { turn: number; step: number }
   /** Closes step `step` of turn `turn`. */

+ 1 - 1
packages/core/session/tests/fork.spec.ts

@@ -29,7 +29,7 @@ function appendClosedTurn(
     content: [{ type: 'text', text }],
     source: { kind: 'user' },
   }), { surfaceOp: 'append' })
-  session.append('turn/end', { turn, reason })
+  session.append('turn/end', { turn, step: 0, reason })
 }
 
 function appendOpenTurn(session: Session, turn: number): void {

+ 14 - 14
packages/core/session/tests/invariant.spec.ts

@@ -27,7 +27,7 @@ describe('session-log invariants', () => {
     const session = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
     expect(() => {
       session.append('turn/start', { turn: 1 })
-      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     }).not.toThrow()
   })
 
@@ -63,7 +63,7 @@ describe('session-log invariants', () => {
         }),
       }, { surfaceOp: 'append' })
       session.append('step/end', { turn: 1, step: 1 })
-      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
     }).not.toThrow()
   })
 
@@ -82,7 +82,7 @@ describe('session-log invariants', () => {
     expect(session.events).toEqual([])
     expect(() => {
       session.append('turn/start', { turn: 1 })
-      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     }).not.toThrow()
   })
 
@@ -94,7 +94,7 @@ describe('session-log invariants', () => {
     ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
     expect(() => {
       session.append('turn/start', { turn: 1 })
-      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     }).not.toThrow()
     expect(warnings).toHaveLength(2)
   })
@@ -112,7 +112,7 @@ describe('session-log invariants', () => {
       type: 'turn/end',
       seq: 0,
       time: 2,
-      data: { turn: 1, reason: { kind: 'completed' } },
+      data: { turn: 1, step: 0, reason: { kind: 'completed' } },
     } as never) }).toThrow(/seq must strictly increase/)
   })
 
@@ -122,12 +122,12 @@ describe('session-log invariants', () => {
     open.append('turn/start', { turn: 1 })
     expect(() => open.append('turn/start', { turn: 2 }))
       .toThrow(/turn 1 is still open/)
-    expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
+    expect(() => open.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } }))
       .toThrow(/does not match open turn 1/)
 
     const second = (await setup()).ctx.sessions.create()
     second.append('turn/start', { turn: 1 })
-    second.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    second.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     expect(() => second.append('turn/start', { turn: 3 }))
       .toThrow(/expected turn 2, got 3/)
 
@@ -166,7 +166,7 @@ describe('session-log invariants', () => {
     nested.append('turn/start', { turn: 1 })
     nested.append('step/start', { turn: 1, step: 1 })
     expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
-    expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
+    expect(() => nested.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } }))
       .toThrow(/while step 1 is still open/)
     expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/)
     expect(() => nested.append('assistant/message', {
@@ -250,7 +250,7 @@ describe('session-log invariants', () => {
       }),
     }, { surfaceOp: 'append' })
     session.append('step/end', { turn: 1, step: 1 })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
 
     session.append('turn/start', { turn: 2 })
     expect(() => session.append('tool/result', {
@@ -290,7 +290,7 @@ describe('session-log invariants', () => {
       }),
     }, { surfaceOp: 'append' })
     session.append('step/end', { turn: 1, step: 1 })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
 
     expect(() => session.append('tool/result', {
       ...original.data,
@@ -323,7 +323,7 @@ describe('session-log invariants', () => {
         error: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
       }, { surfaceOp: 'append' })
       repaired.append('step/end', { turn: 1, step: 1 })
-      repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
+      repaired.append('turn/end', { turn: 1, step: 1, reason: { kind: 'interrupted' } })
     }).not.toThrow()
 
     const unresolved = (await setup()).ctx.sessions.create()
@@ -332,7 +332,7 @@ describe('session-log invariants', () => {
       unresolved.append('step/start', { turn: 1, step: 1 })
       unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
       unresolved.append('step/end', { turn: 1, step: 1 })
-      unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', error: 'boom' } })
+      unresolved.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'boom' } })
     }).not.toThrow()
   })
 
@@ -391,7 +391,7 @@ describe('session-log invariants', () => {
     // Balanced seed: between turns.
     expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [
       { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
-      { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
     ] })).not.toThrow()
     // Unbalanced seed: inside the open turn, which the relation permits.
     const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [
@@ -401,7 +401,7 @@ describe('session-log invariants', () => {
     // Still open afterwards: the boundary moves no cursor.
     expect(() => open.append('turn/start', { turn: 2 }))
       .toThrow(/turn 1 is still open/)
-    expect(() => open.append('turn/end', { turn: 1, reason: { kind: 'completed' } })).not.toThrow()
+    expect(() => open.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })).not.toThrow()
   })
 
   it('removes all listeners when the companion is disposed', async () => {

+ 1 - 1
packages/core/session/tests/properties.spec.ts

@@ -71,7 +71,7 @@ const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
 // A non-message event (trace/replay data — must NOT affect derived history).
 const nonMessageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
   fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1 } }),
-  fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
+  fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, step: 0, reason: { kind: 'completed' } } }),
   fc.constant<Appendable>({ type: 'step/start', data: { turn: 1, step: 1 } }),
   fc.constant<Appendable>({ type: 'step/end', data: { turn: 1, step: 1 } }),
   fc.string().map((text): Appendable => ({ type: 'assistant/chunk', data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text } } })),

+ 5 - 5
packages/core/session/tests/repair.spec.ts

@@ -19,7 +19,7 @@ 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' } } },
+      { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
     ]
     expect(interruptedTurnClosers(balanced)).toEqual([])
   })
@@ -169,7 +169,7 @@ describe('interruptedTurnClosers', () => {
         }),
       } },
       { 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' } } },
+      { type: 'turn/end', seq: 5, time: 5, data: { turn: 1, step: 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: {
@@ -285,7 +285,7 @@ describe('lastActivityTime', () => {
   it('reports the log tail when no boundary is present', () => {
     const events: SessionEvent[] = [
       userTurnStart(1, 0),
-      { type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 1, time: 500, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
     ]
     expect(lastActivityTime(events)).toBe(500)
   })
@@ -293,7 +293,7 @@ describe('lastActivityTime', () => {
   it('skips a trailing boundary in favour of the last real work', () => {
     const events: SessionEvent[] = [
       userTurnStart(1, 0),
-      { type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 1, time: 500, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
       endSeedAt(2, 9_000),
     ]
     // Resumed long after the work, but never worked in again.
@@ -304,7 +304,7 @@ describe('lastActivityTime', () => {
     const events: SessionEvent[] = [
       userTurnStart(1, 0),
       endSeedAt(1, 9_000),
-      { type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
     ]
     expect(lastActivityTime(events)).toBe(9_500)
   })

+ 1 - 1
packages/core/session/tests/request-header.spec.ts

@@ -121,7 +121,7 @@ describe('Session.requestContext', () => {
   /** A turn-enclosed capacity record; the invariant rejects one outside a turn. */
   function seedWith(...records: { provider: string; model: string; contextWindow?: number }[]): SessionEvent[] {
     const events: SessionEvent[] = [{
-      type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
+      type: 'turn/start', seq: 0, time: 1, data: { turn: 1 },
     }]
     for (const data of records) {
       events.push({ type: 'request/context', seq: events.length, time: 1, data })

+ 10 - 10
packages/core/session/tests/session.spec.ts

@@ -48,7 +48,7 @@ describe('Session', () => {
         isError: false,
       }),
     }, { surfaceOp: 'append' })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
 
     const messages = session.deriveMessages()
     expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user'])
@@ -62,7 +62,7 @@ describe('Session', () => {
     // append and persist like any other reason (JSON-serializable, no fields).
     const session = new Session(SessionId('s1'))
     session.append('turn/start', { turn: 1 })
-    session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'max-tokens' } })
 
     const turnEnd = session.events.findLast(e => e.type === 'turn/end')!
     expect(turnEnd.data.reason).toEqual({ kind: 'max-tokens' })
@@ -73,7 +73,7 @@ describe('Session', () => {
   it('round-trips an aborted turn with its cancellation cause', () => {
     const session = new Session(SessionId('aborted'))
     session.append('turn/start', { turn: 1 })
-    session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'aborted', reason: { kind: 'user' } } })
     const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
     expect(replayed.events.slice(0, -1)).toEqual(session.events)
     const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
@@ -132,7 +132,7 @@ describe('Session', () => {
         },
       }),
     }, { surfaceOp: 'append' })
-    original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    original.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
 
     const replayed = new Session(SessionId('s3-replay'), [...original.events])
     expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
@@ -493,7 +493,7 @@ describe('Session', () => {
   it('validates seed events: rejects a non-contiguous seq', () => {
     const gapSeed = [
       { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
-      { type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
+      { type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
     ] as SessionEvent[]
     expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
   })
@@ -508,7 +508,7 @@ describe('Session', () => {
       { type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
         content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
       }) },
-      { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
+      { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } },
     ] as SessionEvent[]
     expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
   })
@@ -519,7 +519,7 @@ describe('Session', () => {
       { type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
         content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
       }), surfaceOp: 'append' as const },
-      { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
+      { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } },
     ] as SessionEvent[]
     const session = new Session(SessionId('seed-ok'), goodSeed)
     expect(session.events.slice(0, 3)).toEqual(goodSeed)
@@ -708,7 +708,7 @@ describe('Session', () => {
         role: 'user' as const,
         content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const },
       }, surfaceOp: 'append' as const },
-      { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
+      { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' as const } } },
     ] as SessionEvent[]
     const session = new Session(SessionId('seed-snapshot'), seed)
     // Mutate the ORIGINAL seed objects after construction: a shared reference
@@ -902,7 +902,7 @@ describe('Session', () => {
     expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError)
     expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError)
 
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     const after = session.events
     expect(before).toHaveLength(1)
     expect(after).toHaveLength(2)
@@ -1636,7 +1636,7 @@ describe('todo/write event', () => {
     const original = new Session(SessionId('t4'))
     original.append('turn/start', { turn: 1 })
     original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
-    original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    original.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     // Seeding a non-surface event with no surfaceOp must not throw.
     const replayed = new Session(SessionId('t4-replay'), [...original.events])
     expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)

+ 4 - 4
packages/core/session/tests/surface.spec.ts

@@ -36,7 +36,7 @@ function surfaceSession(): Session {
       },
     }),
   }, { surfaceOp: 'append' })
-  s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  s.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
   return s
 }
 
@@ -401,7 +401,7 @@ describe('SurfaceManager', () => {
     s.append('turn/start', { turn: 1 })
     s.append('step/start', { turn: 1, step: 1 })
     s.append('step/end', { turn: 1, step: 1 })
-    s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    s.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
     expect(s.surface.nodes.length).toBe(0)
     expect(s.deriveMessages()).toEqual([])
   })
@@ -684,7 +684,7 @@ describe('deriveMessages with surface', () => {
         },
       }),
     }, { surfaceOp: 'append' })
-    s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    s.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
     // Chunks and boundaries are NOT in the surface, so only 2 messages.
     expect(s.deriveMessages()).toHaveLength(2)
   })
@@ -775,7 +775,7 @@ describe('Session.append surface opts', () => {
         }),
       }, surfaceOp: 'append' },
       { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
-      { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
     ]
     const s = new Session(SessionId('nomessage'), seed)
     // The empty assistant/message is on the surface but _deriveOneMessage returns null for it.

+ 2 - 2
packages/core/tools/tests/invariant.spec.ts

@@ -100,7 +100,7 @@ describe('tool-pipeline invariants', () => {
     expect(() => session.append('tool/code-dispatch-start', data)).toThrow(/outside any open turn/)
     session.append('turn/start', { turn: 1 })
     expect(() => session.append('tool/code-dispatch-start', data)).not.toThrow()
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
   })
 
   it('replays enclosed code-dispatch records on late registration', async () => {
@@ -116,7 +116,7 @@ describe('tool-pipeline invariants', () => {
       isError: false,
       content: [{ type: 'text', text: 'ok' }],
     })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     await ctx.plugin(InvariantService)
     await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).resolves.toBeUndefined()
   })

+ 1 - 1
packages/examples/cli-demo/tests/cli.spec.ts

@@ -419,7 +419,7 @@ describe('runOneShot and executeCli', () => {
         source: { kind: 'plugin', plugin: 'test' },
       }))
       other.append('turn/start', { turn: 1 })
-      other.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      other.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     })
     const streamed: { sessionId: string; event: SessionEvent }[] = []
     const result = runOneShot(ctx, {

+ 0 - 2
packages/fs/tool-str-replace-editor/tests/tools.spec.ts

@@ -40,8 +40,6 @@ function agent(ctx: Context, cwd: string): Agent {
     followup: () => {},
     steer: () => {},
     inject: () => {},
-    updateInbox: () => 'not-found',
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }

+ 0 - 3
packages/goal/command-goal/tests/command-goal.spec.ts

@@ -29,13 +29,10 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
     inbox,
     ctx: new Context(),
     get status() { return status },
-    get acceptsNextStep() { return status === 'running' },
     send: () => {},
-    updateInbox: () => 'not-found',
     followup: () => {},
     steer: () => {},
     inject(input) { inbox.append('next-step', input) },
-    reserveTurnAdmission: () => undefined,
     cancel() { status = 'idle' },
     whenIdle() { return Promise.resolve() },
   }

+ 1 - 1
packages/goal/goal-session/tests/goal-session.spec.ts

@@ -994,7 +994,7 @@ describe('same-session goal driving', () => {
     orphan.append('turn/start', {
       turn: 1,
     })
-    orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    orphan.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
 
     const handle = await test.ctx.agents.create({
       sessionId: SessionId('goal-session-disposed'),

+ 2 - 2
packages/goal/goal-session/tests/invariant.spec.ts

@@ -41,7 +41,7 @@ function appendRound(session: Session, turn: number, content = renderGoalRoundPr
   session.append('user/message', createUserMessage({
     content, source,
   }), { surfaceOp: 'append' })
-  session.append('turn/end', { turn, reason: { kind: 'completed' } })
+  session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
 }
 
 async function mount(sessionFirst = false): Promise<{ ctx: Context; session: Session }> {
@@ -73,7 +73,7 @@ describe('goal-session prompt invariants', () => {
       content: [{ type: 'text', text: 'ordinary human message' }],
       source: userSource,
     }), { surfaceOp: 'append' })
-    session.append('turn/end', { turn: 4, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 4, step: 0, reason: { kind: 'completed' } })
 
     const stateSource = {
       kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0,

+ 3 - 6
packages/goal/goal/tests/goal.spec.ts

@@ -38,13 +38,10 @@ function stubAgentForSession(session: Session): StubAgent {
     inbox,
     ctx: new Context(),
     status: 'idle',
-    acceptsNextStep: false,
     send: () => {},
-    updateInbox: () => 'not-found',
     followup: () => {},
     steer: () => {},
     inject(input) { inbox.append('next-step', input) },
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle() { return Promise.resolve() },
   }
@@ -76,7 +73,7 @@ function appendRound(session: Session, ref: GoalRef, round: number): void {
   session.append('user/message', createUserMessage({
     content: [{ type: 'text', text: `round ${round}` }], source,
   }), { surfaceOp: 'append' })
-  session.append('turn/end', { turn, reason: { kind: 'completed' } })
+  session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
 }
 
 describe('GoalService creation and replay', () => {
@@ -584,7 +581,7 @@ describe('goal replay validation', () => {
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'ordinary' }], source,
     }), { surfaceOp: 'append' })
-    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
     expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
   })
 
@@ -726,7 +723,7 @@ describe('goal replay validation', () => {
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'missing' }], source,
     }), { surfaceOp: 'append' })
-    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
     expect(() => foldGoal(session.events)).toThrow('goal message source is invalid')
   })
 

+ 0 - 1
packages/goal/goal/tests/projection.spec.ts

@@ -45,7 +45,6 @@ function liveAgent(ctx: Context, session: Session): Agent {
     inject(input: UserMessage) {
       inbox.append('next-step', input)
     },
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle() { return Promise.resolve() },
   }

+ 1 - 2
packages/goal/tool-goal/tests/tool-goal.spec.ts

@@ -38,7 +38,6 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
     inject(input) {
       this.inbox.append('next-step', input)
     },
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle() { return Promise.resolve() },
   }
@@ -66,7 +65,7 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
 
 /** Close the currently open test turn. */
 function closeTurn(stub: StubAgent, turn: number): void {
-  stub.session.append('turn/end', { turn, reason: { kind: 'completed' } })
+  stub.session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
 }
 
 async function harness(config: toolGoal.Config = {}) {

+ 2 - 2
packages/hooks/hook-protocol/tests/invariant.spec.ts

@@ -54,7 +54,7 @@ describe('hook-protocol invariants', () => {
     await ctx.plugin(InvariantService)
     await ctx.plugin(HookInvariant)
     expect(() => session.append('hook/result', result())).not.toThrow()
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
   })
 
   it('adopts a bare session first observed through publication', async () => {
@@ -87,7 +87,7 @@ describe('hook-protocol invariants', () => {
     await ctx.plugin(SessionStore)
     const session = ctx.sessions.create()
     startTurn(session)
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     session.append('hook/invoked', invoked())
     await ctx.plugin(InvariantService)
     await expect(ctx.plugin(HookInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)

+ 0 - 1
packages/host/apiproxy/src/api/index.ts

@@ -66,7 +66,6 @@ export type {
 // ---- Errors and ids ----
 export { RpcId, transportError } from './rpc.ts'
 export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
-export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
 
 // ---- Fixed session-search product bounds ----
 export {

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-cold.spec.ts

@@ -91,7 +91,7 @@ describe('attached updatedAt excludes end-seed', () => {
     const resumed = ctx.sessions.create(sid('resumed-untouched'), {
       seed: [
         { type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } },
-        { type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
+        { type: 'turn/end', seq: 1, time: worked, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
       ],
       meta: { cwd: '/proj', createdAt: 500 },
     })

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-fork.spec.ts

@@ -54,7 +54,7 @@ function liveAgent(ctx: Context, id: string, turns: number, openTail = false): S
       content: [{ type: 'text', text: `prompt ${String(turn)}` }],
       source: { kind: 'user' },
     }), { surfaceOp: 'append' })
-    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
   }
   if (openTail) {
     session.append('turn/start', { turn: turns + 1 })

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-rename.spec.ts

@@ -62,7 +62,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
       content: [{ type: 'text', text: `prompt ${String(turn)}` }],
       source: { kind: 'user' },
     }), { surfaceOp: 'append' })
-    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
   }
   ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
   return session

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-view.spec.ts

@@ -316,7 +316,7 @@ describe('mux live view computation', () => {
     const session = ctx.sessions.create()
     session.append('turn/start', { turn: 1 })
     session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     // The turn/end above cleared the live table; pairing must fall back to
     // scanning the session's in-memory events.
     session.append('tool/result', {

+ 0 - 3
packages/host/apiproxy/tests/api-proxy-workspace.spec.ts

@@ -46,14 +46,11 @@ function stubAgent(session: Session): Agent {
     session,
     inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
     status: 'idle',
-    acceptsNextStep: false,
     ctx: new Context(),
     send: () => {},
     followup: () => {},
     steer: () => {},
     inject: () => {},
-    updateInbox: () => 'not-found',
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }

+ 3 - 7
packages/llm/llm-retry/tests/invariant.spec.ts

@@ -181,9 +181,7 @@ describe('llm-retry invariants', () => {
 
     const closedTurn = openStep(ctx, 'retry-invariant-closed-turn')
     closedTurn.append('step/end', { turn: 1, step: 1 })
-    closedTurn.append('turn/end', {
-      turn: 1,
-      reason: { kind: 'aborted', reason: { kind: 'user' } },
+    closedTurn.append('turn/end', { turn: 1, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
     })
     expect(() => {
       closedTurn.append('llm/retry', { turn: 1, step: 1, ...normal })
@@ -230,7 +228,7 @@ describe('llm-retry invariants', () => {
     appendRetryTurn(missingEnd, 2)
 
     const nonFailureEnd = ctx.sessions.create(SessionId('retry-invariant-non-failure-end'))
-    nonFailureEnd.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    nonFailureEnd.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     nonFailureEnd.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'idle context' }],
       source: { kind: 'user' },
@@ -238,9 +236,7 @@ describe('llm-retry invariants', () => {
     appendRetryTurn(nonFailureEnd, 2)
 
     const missingStart = ctx.sessions.create(SessionId('retry-invariant-missing-start'))
-    missingStart.append('turn/end', {
-      turn: 1,
-      reason: { kind: 'error', error: failure },
+    missingStart.append('turn/end', { turn: 1, step: 0, reason: { kind: 'error', error: failure },
     })
     appendRetryTurn(missingStart, 2)
 

+ 2 - 6
packages/llm/llm-retry/tests/persistence.spec.ts

@@ -49,12 +49,8 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind)
         failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
       })
       session.append('step/end', { turn: 1, step: 1 })
-      session.append('turn/end', {
-        turn: 1,
-        reason: {
-          kind: 'error',
-          error: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
-        },
+      session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
+      },
       })
 
       expect(session.deriveMessages()).toEqual([])

+ 2 - 2
packages/llm/llm-retry/tests/retry.spec.ts

@@ -337,7 +337,7 @@ describe('provider-routed retry policy', () => {
     expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2)
     expect(agent.session.events.at(-1)).toMatchObject({
       type: 'turn/end',
-      data: { reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } },
+      data: { step: 1, reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } },
     })
   })
 
@@ -450,7 +450,7 @@ describe('provider-routed retry policy', () => {
     expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
     expect(agent.session.events.at(-1)).toMatchObject({
       type: 'turn/end',
-      data: { reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } },
+      data: { step: 1, reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } },
     })
   })
 

+ 2 - 2
packages/llm/llm-retry/tests/transport-recovery.spec.ts

@@ -193,7 +193,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
     expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
     expect(agent.session.events.at(-1)).toMatchObject({
       type: 'turn/end',
-      data: { reason: { kind: 'error', error: { code: 'STREAM_CLOSED' } } },
+      data: { step: 1, reason: { kind: 'error', error: { code: 'STREAM_CLOSED' } } },
     })
   })
 
@@ -235,7 +235,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
     expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2)
     expect(agent.session.events.at(-1)).toMatchObject({
       type: 'turn/end',
-      data: { reason: { kind: 'error', error: { code: 'TRANSPORT' } } },
+      data: { step: 1, reason: { kind: 'error', error: { code: 'TRANSPORT' } } },
     })
   })
 })

+ 8 - 5
packages/plan/plan-mode/src/index.ts

@@ -9,8 +9,9 @@
  * The state in force is folded from the session log (`plan/mode`, last one
  * wins), so resume and fork restore it without a live mirror. User selections
  * are held as pending intent until an in-turn step boundary. The service
- * flushes from `agent/pre-step` before the affected request assembly;
- * same-step request retries reuse their assembly.
+ * projects pending intent into the proposed step assembly, then flushes it
+ * from `agent/pre-step` only when the step is accepted. Same-step request
+ * retries reuse their assembly.
  *
  * The exit tool remains registered while plan mode is inactive so crossing a
  * boundary changes only the prompt section, not the request tool catalog.
@@ -225,9 +226,11 @@ export class PlanModeService extends Service {
     ctx.systemPrompt.section({
       name: 'plan:policy',
       order: 50,
-      text: context => context.agent !== undefined && foldPlanMode(context.agent.session.events)
-        ? this.section
-        : '',
+      text: (context) => {
+        if (context.agent === undefined) return ''
+        const pending = this.pendingIntents.get(context.agent.session)
+        return (pending?.active ?? foldPlanMode(context.agent.session.events)) ? this.section : ''
+      },
     })
 
     // The plan projection unit (session-projection RFC): a pure double-event

+ 3 - 4
packages/plan/plan-mode/tests/invariant.spec.ts

@@ -31,8 +31,7 @@ describe('plan-mode stream invariants', () => {
     expect(() => { ctx.emit('session/event', session, event(true)) }).not.toThrow()
     expect(() => { ctx.emit('session/event', session, event(false)) }).not.toThrow()
     ctx.emit('session/event', session, {
-      type: 'turn/end', seq: 3, time: 3,
-      data: { turn: 1, reason: { kind: 'completed' } },
+      type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' } },
     })
   })
 
@@ -67,7 +66,7 @@ describe('plan-mode stream invariants', () => {
     const session = ctx.sessions.create()
     session.append('turn/start', { turn: 1 })
     session.append('plan/mode', { active: 'plan' as unknown as boolean })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     await ctx.plugin(InvariantService, { enabled: true })
 
     await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/expected a boolean/)
@@ -79,7 +78,7 @@ describe('plan-mode stream invariants', () => {
     const session = ctx.sessions.create()
     session.append('turn/start', { turn: 1 })
     session.append('plan/mode', { active: true })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     await ctx.plugin(InvariantService, { enabled: true })
 
     await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).resolves.toBeUndefined()

+ 6 - 6
packages/plan/plan-mode/tests/plan-mode.spec.ts

@@ -96,7 +96,7 @@ function openTurn(session: Session, turn = 0): void {
 
 /** Close the open turn (the between-turns shape: selections commit immediately). */
 function closeTurn(session: Session, turn = 0): void {
-  session.append('turn/end', { turn, reason: { kind: 'completed' } })
+  session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
 }
 
 /** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
@@ -799,17 +799,17 @@ describe('exit_plan_mode', () => {
     expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
   })
 
-  it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => {
+  it('an approved exit projects the next assembly before the boundary and never removes the tool', async () => {
     const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
     const approved = await callExit(ctx, agent)
     expect(approved.isError).toBe(false)
-    // Calls of the SAME assistant response (no boundary between) were
-    // requested under the plan-shaped header — the fold stays plan for that
-    // whole batch; the boundary flush is what flips the next step.
+    // Calls of the SAME assistant response were requested under the existing
+    // plan-shaped header. Pending state shapes only the proposed next
+    // assembly; the accepted boundary then commits the matching durable fold.
     expect(foldPlanMode(agent.session.events)).toBe(true)
     const assembly = await ctx.systemPrompt.assemble({ agent })
     expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
-    expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
+    expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
     await boundary(ctx, agent, 'step-start')
     expect(foldPlanMode(agent.session.events)).toBe(false)
     const afterExit = await ctx.systemPrompt.assemble({ agent })

+ 1 - 1
packages/plan/plan-mode/tests/projection.spec.ts

@@ -60,7 +60,7 @@ function runPlanCommand(session: Session, args: string, index: number): void {
 function commitPlanMode(session: Session, active: boolean, turn: number): void {
   session.append('turn/start', { turn })
   session.append('plan/mode', { active })
-  session.append('turn/end', { turn, reason: { kind: 'completed' } })
+  session.append('turn/end', { turn, step: 0, reason: { kind: 'completed' } })
 }
 
 describe('plan projection unit', () => {

+ 6 - 6
packages/pty/pty-local/tests/index.spec.ts

@@ -43,8 +43,8 @@ function agent(ctx: Context, cwd?: string): Agent {
   const session = new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } })
   return {
     id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
-    status: 'idle', acceptsNextStep: false, ctx,
-    send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
+    status: 'idle', ctx,
+    send: () => {},
     followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
   }
 }
@@ -258,8 +258,8 @@ describe('pty-local plugin shape', () => {
     const ownerFiber = await ctx.plugin(() => {})
     const owner: Agent = {
       id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
-      status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
-      send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
+      status: 'idle', ctx: ownerFiber.ctx,
+      send: () => {},
       followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
     }
     ctx.agents.register(owner)
@@ -303,8 +303,8 @@ describe('pty-local plugin shape', () => {
     const ownerFiber = await ctx.plugin(() => {})
     const owner: Agent = {
       id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
-      status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
-      send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
+      status: 'idle', ctx: ownerFiber.ctx,
+      send: () => {},
       followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
     }
     ctx.agents.register(owner)

+ 2 - 2
packages/pty/pty-local/tests/local.spec.ts

@@ -36,8 +36,8 @@ function stubAgent(ctx: Context, rawId: string): Agent {
   const session = new Session(id)
   return {
     id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
-    status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
-    send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
+    status: 'idle', ctx: scope.ctx,
+    send: () => {},
     followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
   }
 }

+ 0 - 2
packages/pty/pty/tests/service.spec.ts

@@ -33,8 +33,6 @@ function stubAgent(ctx: Context, rawId: string): Agent {
     followup: () => {},
     steer: () => {},
     inject: () => {},
-    updateInbox: () => 'not-found',
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }

+ 0 - 2
packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts

@@ -50,8 +50,6 @@ function agent(ctx: Context, cwd: string): Agent {
     followup: () => {},
     steer: () => {},
     inject: () => {},
-    updateInbox: () => 'not-found',
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }

+ 0 - 2
packages/pty/tool-bash-persistent/tests/tools.spec.ts

@@ -46,8 +46,6 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
     followup: () => {},
     steer: () => {},
     inject: () => {},
-    updateInbox: () => 'not-found',
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }

+ 2 - 2
packages/pty/tool-pty/tests/loader-composition.spec.ts

@@ -41,8 +41,8 @@ function agent(ctx: Context): Agent {
   const session = new Session(id)
   const value: Agent = {
     id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
-    status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
-    send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
+    status: 'idle', ctx: scope.ctx,
+    send: () => {},
     followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
   }
   ctx.agents.register(value)

+ 2 - 2
packages/pty/tool-pty/tests/tools.spec.ts

@@ -19,8 +19,8 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
   const session = new Session(id)
   const agent: Agent = {
     id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
-    status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
-    send: () => {}, updateInbox: () => 'not-found', reserveTurnAdmission: () => undefined,
+    status: 'idle', ctx: scope.ctx,
+    send: () => {},
     followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
   }
   ctx.agents.register(agent)

+ 18 - 18
packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -64,7 +64,7 @@ function appendClosedTurn(session: Session): void {
     content: [{ type: 'text', text: 'hello' }],
     source: { kind: 'user' },
   }), { surfaceOp: 'append' })
-  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
 }
 
 // Run the shared backend contract against the real JSONL backend.
@@ -224,7 +224,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
         }),
       }, surfaceOp: 'append', sourceEventSeqs: [2, 3] },
       { type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
-      { type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 6, time: 7, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
     ]
     await ctx.sessionPersistence.create(m)
     await ctx.sessionPersistence.append(m.id, log)
@@ -344,7 +344,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
       JSON.stringify(toHeaderLine(m)),
       JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
       JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }),
-      JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
+      JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, step: 0, reason: { kind: 'completed' } } }),
       '',
     ].join('\n'))
 
@@ -419,7 +419,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
     // The next append continues at seq 10 (the balanced length).
     const turn3 = [
       { type: 'turn/start', seq: 10, time: 11, data: { turn: 3 } },
-      { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, step: 0, reason: { kind: 'completed' } } },
     ] as SessionEvent[]
     await ctx.sessionPersistence.append(m.id, turn3)
     const reloaded = await ctx.sessionPersistence.load(m.id)
@@ -438,7 +438,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
     await ctx.sessionPersistence.load(m.id)
     await ctx.sessionPersistence.append(m.id, [
       { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
-      { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
     ] as SessionEvent[])
     const after = await readFile(rawLogPath(root, undefined, m.id), 'utf8')
     // the committed prefix is byte-for-byte intact at the head of the file
@@ -466,7 +466,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
 
     const turn2 = [
       { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
-      { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
     ] as SessionEvent[]
     // The append rejects, but the partial bytes are truncated back: the file is
     // its pre-append size and the cursor is unchanged.
@@ -529,7 +529,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
     mutableHeader(loaded.meta).cwd = '/evil'
     await ctx.sessionPersistence.append(m.id, [
       { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
-      { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
     ] as SessionEvent[])
     // The append landed in the ORIGINAL /proj log, not beside an /evil path.
     const reloaded = await ctx.sessionPersistence.load(m.id)
@@ -606,8 +606,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () =>
     b.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'B' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' })
-    a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
-    b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    a.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
+    b.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     await ctx.sessions.flush(a)
     await ctx.sessions.flush(b)
 
@@ -694,7 +694,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
       JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }),
       JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
       JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
-      JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
+      JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 1, reason: { kind: 'completed' } } }),
     ].join('\n') + '\n'
     // A turn/end exists, so the prefix up to it is committed — but it has a hole.
     // Truncating it would silently drop committed data → unloadable.
@@ -705,7 +705,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
     const log = [
       JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1, delegationDepth: 0 }),
       '{not json', // corrupt, sits in the committed region (a turn/end follows)
-      JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
+      JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } }),
     ].join('\n') + '\n'
     expect(() => scanLog(Buffer.from(log))).toThrow(/unparsable committed event/)
   })
@@ -733,7 +733,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
     const log = [
       JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1, delegationDepth: 0 }),
       JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
-      JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
+      JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'completed' } } }),
       JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail
     ].join('\n') + '\n'
     const { events } = scanLog(Buffer.from(log))
@@ -777,7 +777,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
         }),
       }, surfaceOp: 'append', sourceEventSeqs: [2, 3, 4, 5, 6] },
       { type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } },
-      { type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 9, time: 10, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
     ]
   }
 
@@ -855,7 +855,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
       JSON.stringify({ type: 'session', version: 0, id: 'rows', createdAt: 1, delegationDepth: 0 }),
       JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
       JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
-      JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }),
+      JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, step: 1, reason: { kind: 'completed' } } }),
     ].join('\n') + '\n'
     const { events } = scanLog(Buffer.from(logText))
     expect(events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4])
@@ -867,7 +867,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
       JSON.stringify({ type: 'session', version: 0, id: 'bad-row', createdAt: 1, delegationDepth: 0 }),
       // dt arity mismatch — row validation throws, so the line is a committed hole.
       JSON.stringify({ type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] } }),
-      JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
+      JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, step: 1, reason: { kind: 'completed' } } }),
     ].join('\n') + '\n'
     expect(() => scanLog(Buffer.from(logText))).toThrow(/unparsable committed event/)
   })
@@ -1154,7 +1154,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
     const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
       const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
       a.append('turn/start', { turn: 1 })
-      a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      a.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     }, { inject: ['sessions'] }))
     for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
     await firstFiber.dispose()
@@ -1232,7 +1232,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
     await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
     await ctx2.sessionPersistence.append(m.id, [
       { type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
-      { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
     ] as SessionEvent[])
     const loaded = await ctx2.sessionPersistence.load(m.id)
     expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
@@ -1282,7 +1282,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
     }), { surfaceOp: 'append' })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     // Make the durable materialize fail on the next flush.
     const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise<void> }
     const origMat = backend.materialize.bind(backend)

+ 3 - 3
packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts

@@ -286,7 +286,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
     const before = await readFile(path)
     const secondTurn = [
       { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
-      { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
     ] as SessionEvent[]
     await ctx.sessionPersistence.append(header.id, secondTurn)
 
@@ -428,7 +428,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
     const path = logPath(root, header.cwd, header.id, 'zstd')
     const secondTurn = [
       { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
-      { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
     ] as SessionEvent[]
     const frame = await compressZstdFrame(secondTurn.map(e => JSON.stringify(e)).join('\n') + '\n')
     await appendFile(path, frame.subarray(0, -1))
@@ -476,7 +476,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
     })
     const secondTurn = [
       { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
-      { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
     ] as SessionEvent[]
     await expect(ctx.sessionPersistence.append(header.id, secondTurn)).rejects.toThrow(/simulated Zstandard fsync failure/)
     expect(await readFile(path)).toEqual(before)

+ 9 - 9
packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts

@@ -135,7 +135,7 @@ describe('scanRows', () => {
     const gapped: SessionEvent[] = [
       { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
       { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
-      { type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 3, time: 3, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
     ]
     expect(() => scanRows(rows(gapped))).toThrow(/seq gap in committed region/)
   })
@@ -143,7 +143,7 @@ describe('scanRows', () => {
   it('throws on an unparsable row inside the committed region', () => {
     const withCorruptCommitted: EventRow[] = [
       { seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end
-      { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
+      { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, step: 0, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
     ]
     expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
   })
@@ -185,7 +185,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
     const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
     insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1 }))
     insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } }))
-    insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } }))
+    insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, step: 0, reason: { kind: 'completed' } }))
     db.close()
 
     const mounted = await backend(path)
@@ -252,7 +252,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
     // length (seq 10) and a reload round-trips identically.
     await ctx2.sessionPersistence.append(m.id, [
       { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
-      { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, step: 0, reason: { kind: 'completed' } } },
     ])
     const reloaded = await ctx2.sessionPersistence.load(m.id)
     expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
@@ -478,7 +478,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
     // load physically deleted the corrupt tail row, so a fresh append continues.
     await b2.ctx.sessionPersistence.append(m.id, [
       { type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } },
-      { type: 'turn/end', seq: 7, time: 9, data: { turn: 2, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 7, time: 9, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
     ])
     const reloaded = await b2.ctx.sessionPersistence.load(m.id)
     expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
@@ -704,7 +704,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
     await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2
     const turn2: SessionEvent[] = [
       { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
-      { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
     ]
     // b1 commits seq 6..7 first.
     await b1.ctx.sessionPersistence.append(m.id, turn2)
@@ -799,7 +799,7 @@ describe('surface field round-trip', () => {
         data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
         source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' },
       { seq: 1, type: 'turn/end', time: 2,
-        data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
+        data: JSON.stringify({ turn: 1, step: 0, reason: { kind: 'completed' } }),
         source_event_seqs: null, surface_op: null },
     ]
     const { preserved } = scanRows(rows)
@@ -831,7 +831,7 @@ describe('surface field round-trip', () => {
       }),
     }, { surfaceOp: 'append', sourceEventSeqs: [2] })
     session.append('step/end', { turn: 1, step: 1 })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
     await ctx.sessions.flush(session)
     const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface'))
     expect(loaded.events).toHaveLength(6)
@@ -857,7 +857,7 @@ describe('surface field round-trip', () => {
         source: { kind: 'user' },
       }),
     }, { surfaceOp: 'append' })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     await ctx.sessions.flush(session)
     const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq'))
     expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append')

+ 2 - 2
packages/session-persistence/session-persistence/tests/contract.ts

@@ -53,7 +53,7 @@ export function oneTurnLog(): SessionEvent[] {
       }),
     }, surfaceOp: 'append' },
     { type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
-    { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
+    { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
   ]
 }
 
@@ -158,7 +158,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
         // the balanced length (seq 10), and a reload round-trips identically.
         await persistence.append(m.id, [
           { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
-          { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
+          { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, step: 0, reason: { kind: 'completed' } } },
         ])
         const reloaded = await persistence.load(m.id)
         expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])

+ 18 - 18
packages/session-persistence/session-persistence/tests/coordinator-contract.ts

@@ -115,7 +115,7 @@ function legacyMessageLog(): SessionEvent[] {
       surfaceOp: { op: 'replace', start: 5, end: 5 },
     },
     { type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } },
-    { type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
+    { type: 'turn/end', seq: 9, time: 10, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
   ] as unknown as SessionEvent[]
 }
 
@@ -215,7 +215,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         const live = ctx.sessions.create(id, { seed: [start], meta: header })
         await expect(loading).rejects.toThrow(/live turn is open/)
 
-        live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        live.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
         await ctx.sessions.flush(live)
         const loaded = await ctx.sessionPersistence.load(id)
         // The constructor's end-seed event persisted between the stored
@@ -303,7 +303,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         expect(() => {
           ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
         }).toThrow(TypeError)
-        session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
         await ctx.sessions.flush(session)
 
         const loaded = await ctx.sessionPersistence.load(SessionId('mutate'))
@@ -511,7 +511,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } })
         await second.ctx.sessions.flush(s2) // let onCreated adopt
         s2.append('turn/start', { turn: 2 })
-        s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
+        s2.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
         await second.ctx.sessions.flush(s2)
 
         const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
@@ -536,7 +536,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       session.append('user/message', createUserMessage({
         content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
       }), { surfaceOp: 'append' })
-      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
 
       const fiber = await fix.mount(ctx)
       try {
@@ -560,7 +560,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       session.append('user/message', createUserMessage({
         content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' },
       }), { surfaceOp: 'append' })
-      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
       // No explicit flush — dispose must drain.
       await fiber.dispose()
 
@@ -588,7 +588,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         session.append('user/message', createUserMessage({
           content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
         }), { surfaceOp: 'append' })
-        session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
         await ctx.sessions.flush(session)
 
         // Hot-reload: dispose instance 1, mount instance 2 over the same storage while the
@@ -600,7 +600,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         session.append('user/message', createUserMessage({
           content: [{ type: 'text', text: 'again' }], source: { kind: 'user' },
         }), { surfaceOp: 'append' })
-        session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
+        session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
         await expect(ctx.sessions.flush(session)).resolves.not.toThrow()
 
         const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt'))
@@ -620,7 +620,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         // Instance 1 flushes turn 1.
         const backend1 = await fix.mount(ctx)
         session.append('turn/start', { turn: 1 })
-        session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
         await ctx.sessions.flush(session)
 
         // Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT
@@ -628,7 +628,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         // backend never buffered it via session/event.
         await backend1.dispose()
         session.append('turn/start', { turn: 2 })
-        session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
+        session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
 
         // Instance 2 adopts the stored prefix (turn 1) and MUST also persist the
         // live suffix (turn 2) carried in the session's events.
@@ -661,7 +661,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         // The live session is still the authority: it appends the REAL step/turn
         // end. Adoption must truncate the torn tail but NOT synthesize closers.
         session.append('step/end', { turn: 1, step: 1 })
-        session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
         await ctx.sessions.flush(session)
 
         const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open'))
@@ -721,7 +721,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         }, { inject: ['sessions'] }))
         await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined()
         reuse.append('turn/start', { turn: 1 })
-        reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        reuse.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
         await ctx.sessions.flush(reuse)
         const loaded = await ctx.sessionPersistence.load(SessionId('abandoned'))
         expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
@@ -742,7 +742,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         await ctx.sessions.flush(first)
         // Append a turn but do NOT flush — events sit in the write-behind buffer.
         first.append('turn/start', { turn: 1 })
-        first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        first.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
         await firstFiber.dispose()
 
         // Disposal is an observe-only notification. Poll storage rather than
@@ -772,7 +772,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         session.append('user/message', createUserMessage({
           content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
         }), { surfaceOp: 'append' })
-        session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
         await ctx.sessions.flush(session)
         // Re-emit session/created for the SAME live session (idempotent initFor).
         ctx.emit(scopeTarget(session, undefined), 'session/created', session)
@@ -848,7 +848,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
           cont = inner.sessions.create(SessionId('claim'), { seed: [
             ...events,
             { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
-            { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
+            { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
           ], meta: { cwd: WORK, createdAt: 2000 } })
         }, { inject: ['sessions'] }))
         await ctx.sessions.flush(cont)
@@ -940,7 +940,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       try {
         await second.ctx.sessionPersistence.append(SessionId('adopt-append'), [
           { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
-          { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
+          { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
         ])
         const loaded = await second.ctx.sessionPersistence.load(SessionId('adopt-append'))
         expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
@@ -1043,7 +1043,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         session.append('user/message', createUserMessage({
           content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
         }), { surfaceOp: 'append' })
-        session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
         await ctx.sessions.flush(session)
         const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate'))
         expect(loaded.events).toHaveLength(3)
@@ -1100,7 +1100,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         // (seq 10) and a reload round-trips identically.
         await second.ctx.sessionPersistence.append(SessionId('torn'), [
           { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
-          { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
+          { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, step: 0, reason: { kind: 'completed' } } },
         ])
         const reloaded = await second.ctx.sessionPersistence.load(SessionId('torn'))
         expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])

+ 9 - 9
packages/session-persistence/session-persistence/tests/persistence.spec.ts

@@ -240,7 +240,7 @@ describe('PersistenceCoordinator eager writes', () => {
       session.append('turn/start', { turn: 1 })
       await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
 
-      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
       appendGate.resolve(true)
 
       await vi.waitFor(() => {
@@ -273,7 +273,7 @@ describe('PersistenceCoordinator eager writes', () => {
       const session = ctx.sessions.create(SessionId('eager-flush-retry'))
       await ctx.sessions.flush(session)
       session.append('turn/start', { turn: 1 })
-      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
       await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
 
       const barriers = [ctx.sessions.flush(session), ctx.sessions.flush(session)]
@@ -535,7 +535,7 @@ describe('PersistenceCoordinator observation cancellation', () => {
         session = inner.sessions.create(id)
       }, { inject: ['sessions'] }))
       session.append('turn/start', { turn: 1 })
-      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
       // Dispose the session so retirement starts; its append is gated, so the
       // retirement promise stays pending in the coordinator.
       await sessionFiber.dispose()
@@ -678,7 +678,7 @@ describe('PersistenceCoordinator retirement', () => {
       await ctx.sessions.flush(first)
       backend.beforeAppend = async () => { await appendGate.promise }
       first.append('turn/start', { turn: 1 })
-      first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      first.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
       await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
       await firstFiber.dispose()
 
@@ -718,7 +718,7 @@ describe('PersistenceCoordinator retirement', () => {
       await ctx.sessions.flush(first)
       backend.beforeAppend = async () => { await appendGate.promise }
       first.append('turn/start', { turn: 1 })
-      first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      first.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
       await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
       await firstFiber.dispose()
       const baselineLoads = backend.loadAttempts
@@ -782,7 +782,7 @@ describe('PersistenceCoordinator retirement', () => {
         type: 'turn/end',
         seq: 1,
         time: 2,
-        data: { turn: 1, reason: { kind: 'completed' } },
+        data: { turn: 1, step: 0, reason: { kind: 'completed' } },
       }])
 
       await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
@@ -825,7 +825,7 @@ describe('PersistenceCoordinator retirement', () => {
         session = inner.sessions.create(SessionId('retry-retirement'))
       }, { inject: ['sessions'] }))
       session.append('turn/start', { turn: 1 })
-      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
       await sessionFiber.dispose()
 
       await vi.waitFor(() => {
@@ -869,7 +869,7 @@ describe('PersistenceCoordinator retirement', () => {
         session = inner.sessions.create(SessionId('inflight-retirement'))
       }, { inject: ['sessions'] }))
       session.append('turn/start', { turn: 1 })
-      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
       await sessionFiber.dispose()
       await vi.waitFor(() => {
         expect(backend.appendAttempts).toBe(1)
@@ -1054,7 +1054,7 @@ describe('SessionPersistence service registration', () => {
           session = inner.sessions.create(SessionId(`disposed-${index}`))
         }, { inject: ['sessions'] }))
         session.append('turn/start', { turn: 1 })
-        session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
         await ctx.sessions.flush(session)
         await sessionFiber.dispose()
       }

+ 2 - 2
packages/session-projection/session-projection-cache/tests/cache.spec.ts

@@ -93,7 +93,7 @@ const mark = (session: Session, marks: string[]): SessionEvent =>
   session.append('cache-test/mark', { marks })
 
 const endTurn = (session: Session): SessionEvent =>
-  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
 
 /** The stored medium record for one session id (undefined = never written). */
 function storedRecord(pool: MemoryMediaPool, id: Session['id']) {
@@ -226,7 +226,7 @@ describe('SessionProjectionCache cold read', () => {
     for (const m of marks) {
       events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } })
     }
-    events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } })
+    events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, step: 0, reason: { kind: 'completed' } } })
     return events
   }
 

+ 4 - 4
packages/session-projection/session-projection/tests/registry.spec.ts

@@ -225,7 +225,7 @@ describe('SessionProjectionRegistry drive', () => {
     ctx.sessionProjections.register(countUnit())
     const tail: SessionEvent[] = [
       { type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } },
-      { type: 'turn/end', seq: 4, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 4, time: 4, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
     ]
     // marks row usable (watermark 2, tail starts at 3); count row mismatched — but
     // a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read.
@@ -262,7 +262,7 @@ describe('SessionProjectionRegistry drive', () => {
     }
     const tail: SessionEvent[] = [
       { type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } },
-      { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, step: 0, reason: { kind: 'completed' } } },
     ]
     const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3)
     expect(snapshot.asOfSeq).toBe(4)
@@ -302,7 +302,7 @@ describe('SessionProjectionRegistry drive', () => {
     const floor = ctx.sessionProjections.restoreFloor(rows)
     expect(floor).toBe(9)
     // …an intact log serves the anchor event and the checkpoint stands as-is.
-    const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }
+    const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, step: 0, reason: { kind: 'completed' } } }
     expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10)
     // …while a log crash-repaired down to fewer events returns an empty tail:
     // the row overreaches the proven end and a tail read cannot fix this key.
@@ -310,7 +310,7 @@ describe('SessionProjectionRegistry drive', () => {
     // The full re-read discards the overreaching row and refolds from init.
     const events: SessionEvent[] = [
       { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
-      { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, step: 0, reason: { kind: 'completed' } } },
     ]
     const { snapshot } = ctx.sessionProjections.restore(rows, events, 0)
     expect(snapshot.asOfSeq).toBe(1)

+ 1 - 1
packages/session-query/session-query-sqlite/tests/sqlite.spec.ts

@@ -340,7 +340,7 @@ describe('SQLite session search', () => {
       { type: 'user/message', seq: 2, time: 12, data: createUserMessage({
         content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' },
       }), surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
-      { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'error', error: 'needle failure' } } },
+      { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, step: 1, reason: { kind: 'error', error: 'needle failure' } } },
     ]
     ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } })
     ctx.sessions.create(SessionId('b'), { seed: messageEvents('needle peer', 12), meta: { createdAt: 20 } })

+ 2 - 2
packages/session-query/session-query/tests/search-helpers.spec.ts

@@ -136,7 +136,7 @@ describe('session-query semantic extraction', () => {
       [{ kind: 'future-status' } as never, ''],
     ]
     for (const [reason, text] of reasons) {
-      expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason } })).toBe(text)
+      expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, step: 1, reason } })).toBe(text)
     }
     const structural: SessionEvent[] = [
       { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
@@ -167,7 +167,7 @@ describe('session-query document and filter helpers', () => {
         },
       }),
     }, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
-    { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } },
+    { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, step: 1, reason: { kind: 'interrupted' } } },
   ]
 
   it('classifies every event and omits non-semantic documents', () => {

+ 1 - 1
packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts

@@ -39,7 +39,7 @@ describe('all-messages LLM title provider', () => {
     seeded.append('session/title', {
       title: 'Inherited fallback', messageSeqs: [inherited.seq], source: { kind: 'fallback' },
     })
-    seeded.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    seeded.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
 
     const ctx = new Context()
     await ctx.plugin(LlmService)

+ 1 - 1
packages/session-title/session-title-llm/tests/llm.spec.ts

@@ -89,7 +89,7 @@ function request(ctx: Context, signal = new AbortController().signal): SessionTi
     content: [{ type: 'text', text: '第二个问题' }],
     source: { kind: 'user' },
   }), { surfaceOp: 'append' })
-  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
   return {
     session,
     messages: [

+ 1 - 1
packages/session-title/session-title/tests/persistence.spec.ts

@@ -30,7 +30,7 @@ async function appendPersistedTitle(ctx: Context, id: ReturnType<typeof SessionI
     content: [{ type: 'text', text: 'Persist this session title' }],
     source: { kind: 'user' },
   }), { surfaceOp: 'append' })
-  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
   await ctx.sessionTitle.refresh(session)
 }
 

+ 4 - 4
packages/session-title/session-title/tests/provider.spec.ts

@@ -58,7 +58,7 @@ describe('SessionTitleService provider lifecycle', () => {
     })
     const inheritedMessage = appendHumanPrompt(parent, 'Inherited title prompt')
     await settle()
-    parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    parent.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
 
     const child = ctx.sessions.fork(parent, undefined, SessionId('title-child'))
     expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent))
@@ -81,7 +81,7 @@ describe('SessionTitleService provider lifecycle', () => {
     await settle()
     appendRoute(child)
     await settle()
-    child.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
+    child.append('turn/end', { turn: 2, step: 0, reason: { kind: 'completed' } })
     expect(firstGenerate).not.toHaveBeenCalled()
     await disposeFirst()
 
@@ -101,7 +101,7 @@ describe('SessionTitleService provider lifecycle', () => {
     await settle()
     appendRoute(child, 'change')
     await settle()
-    child.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
+    child.append('turn/end', { turn: 3, step: 0, reason: { kind: 'completed' } })
 
     expect(allGenerate).toHaveBeenCalledOnce()
     expect(ctx.sessionTitle.get(child)).toMatchObject({
@@ -287,7 +287,7 @@ describe('SessionTitleService provider lifecycle', () => {
     appendRoute(session)
     await settle()
     session.append('step/end', { turn: 1, step: 1 })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
 
     session.append('turn/start', {
       turn: 2,

+ 2 - 2
packages/session-title/session-title/tests/service-contracts.spec.ts

@@ -168,7 +168,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
       turn: 1,
     })
     const source = appendPrompt(seed, 'Create exactly one fallback title')
-    seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    seed.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events })
 
     const results = await Promise.all([
@@ -210,7 +210,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
     const session = startSession(ctx, 'refresh-order')
     const source = appendPrompt(session, 'Keep the newest explicit refresh')
     await settle()
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     const requests: SessionTitleProviderRequest[] = []
     const results: Array<ReturnType<typeof deferred<SessionTitleProviderResult>>> = []
     ctx.sessionTitle.register({

+ 0 - 6
packages/skill/tool-skill/tests/tool-skill.spec.ts

@@ -46,13 +46,10 @@ function agentForCwd(cwd: string): Agent {
     session,
     inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
     status: 'idle',
-    acceptsNextStep: false,
     send: () => {},
     followup: () => {},
     steer: () => {},
     inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
-    updateInbox: () => 'not-found',
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }
@@ -65,14 +62,11 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
     session,
     inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
     status: 'running',
-    acceptsNextStep: false,
     ctx: new Context(),
     send: () => {},
     followup: () => {},
     steer: () => {},
     inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
-    updateInbox: () => 'not-found',
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }

+ 4 - 4
packages/support/acp-snapshot/tests/harness.spec.ts

@@ -573,7 +573,7 @@ describe('runScenario', () => {
         file: 'project/main/session.jsonl',
         lines: [
           { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
-          { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
+          { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'aborted' } } },
         ],
       }],
     })
@@ -641,7 +641,7 @@ describe('runScenario', () => {
         file: 'project/main/session.jsonl',
         lines: [
           { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
-          { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
+          { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'aborted' } } },
           { type: 'session/title', seq: 2, time: 3, data: { title: 'Late title' } },
         ],
       }],
@@ -721,7 +721,7 @@ describe('runScenario', () => {
         lines: [
           { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
           { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
-          { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'stop' } } },
+          { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, step: 0, reason: { kind: 'stop' } } },
         ],
       }],
     })
@@ -800,7 +800,7 @@ describe('runScenario', () => {
         lines: [
           { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
           { type: 'session/title', seq: 1, time: 1, data: { title: 'Early title' } },
-          { type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
+          { type: 'turn/end', seq: 2, time: 2, data: { turn: 1, step: 0, reason: { kind: 'aborted' } } },
         ],
       }],
     })

+ 2 - 2
packages/support/llm-replay/tests/llm-replay.spec.ts

@@ -159,7 +159,7 @@ describe('deriveReplayScript', () => {
     const events: SessionEvent[] = [
       { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1 } },
       ...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)),
-      { type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, reason: { kind: 'completed' } } },
+      { type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
     ]
     expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }])
   })
@@ -182,7 +182,7 @@ describe('deriveReplayScript', () => {
     const events: SessionEvent[] = [
       chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }),
       chunkEvent(2, 1, 1, { type: 'text-delta', index: 0, text: 'par' }),
-      { type: 'turn/end', seq: 3, time: 0, data: { turn: 1, reason: { kind: 'error', error: 'x' } } },
+      { type: 'turn/end', seq: 3, time: 0, data: { turn: 1, step: 1, reason: { kind: 'error', error: 'x' } } },
     ]
     expect(() => deriveReplayScript(events)).toThrow(/without a finish chunk.*replay\.override\.json/s)
   })

+ 0 - 2
packages/tasks/tasks-local/tests/tasks.spec.ts

@@ -30,8 +30,6 @@ function stubAgent(ctx: Context, rawId: string): Agent {
     followup: () => {},
     steer: () => {},
     inject: () => {},
-    updateInbox: (): 'not-found' => 'not-found',
-    reserveTurnAdmission: () => undefined,
     cancel() {},
     whenIdle() { return Promise.resolve() },
   }

+ 1 - 1
packages/telemetry/session-telemetry-otel/tests/otel.spec.ts

@@ -113,7 +113,7 @@ describe('TelemetryOtel wire', () => {
     const { ctx, fiber } = await boot(url)
     const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
     session.append('turn/start', { turn: 1 })
-    session.append('turn/end', { turn: 1, reason: { kind: 'error', error: 'boom' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'error', error: 'boom' } })
     await fiber.dispose()
 
     expect(captures.length).toBeGreaterThan(0)

+ 6 - 6
packages/telemetry/session-telemetry/tests/telemetry.spec.ts

@@ -126,7 +126,7 @@ describe('TelemetryCoordinator capture', () => {
       }),
     }, { surfaceOp: 'append' })
     session.append('telemetry-test/opaque', { payload: { nested: [] } })
-    session.append('turn/end', { turn: 1, reason: { kind: 'error', error: 'boom' } })
+    session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'boom' } })
     const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity])
     expect(severities).toEqual([
       ['turn/start', 'info'],
@@ -180,7 +180,7 @@ describe('TelemetryCoordinator adoption', () => {
       apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
     })
     const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.events], meta: {} })
-    child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    child.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     ctx.sessions.enter(child)
     ctx.sessions.announce(child)
 
@@ -234,7 +234,7 @@ describe('TelemetryCoordinator adoption', () => {
       inject: ['sessions'],
       apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
     })
-    child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    child.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     const record = backend.ledger().find(r => r.attributes['session.id'] === 'stitch-child')!
     expect(record.attributes['session.parent_id']).toBe('stitch-parent')
     expect(record.attributes['session.seed_length']).toBe(2)
@@ -272,7 +272,7 @@ describe('TelemetryCoordinator adoption', () => {
     await fiber.dispose()
     // The reload window: appends while no telemetry listener is registered.
     session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'mid-step continuation' } })
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
 
     const second = new FakeBackend()
     await ctx.plugin({
@@ -292,7 +292,7 @@ describe('TelemetryCoordinator adoption', () => {
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
     const session = liveSession(ctx, 'partial')
     appendTurn(session)
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     // The backend rejects exactly the middle historical event: fail-closed
     // must withhold THAT record only — an adoption replay that dies on the
     // first contained failure would silently skip the rest of the log while
@@ -415,7 +415,7 @@ describe('TelemetryCoordinator lifecycle and containment', () => {
     expect(() => session.append('turn/start', { turn: 1 })).not.toThrow()
     expect(warn).toHaveBeenCalled()
     backend.emitError = undefined
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     expect(backend.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end'])
   })
 

+ 1 - 1
packages/todo/tool-todo/tests/projection.spec.ts

@@ -97,7 +97,7 @@ describe('todos projection provider', () => {
     seedMessage(session)
     const list: TodoItem[] = [{ content: 'done', status: 'completed' }]
     session.append('todo/write', { todos: list })
-    session.append('turn/end', { turn: 0, reason: { kind: 'completed' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     expect((await bench.tailProjections())?.values.todos).toEqual(list)
     session.append('turn/start', { turn: 1 })
     const cleared = await bench.tailProjections()

+ 2 - 2
packages/ui/permission/tests/permission.spec.ts

@@ -220,8 +220,8 @@ describe('new-session default', () => {
       defaultPreset: 'danger-full-access',
     })
     const legacy = freshSession('legacy-source')
-    legacy.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
-    legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    legacy.append('turn/start', { turn: 1 })
+    legacy.append('turn/end', { turn: 1, step: 0, reason: { kind: 'completed' } })
     const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events })
     expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
     expect(resumed.events.slice(-3).map(event => event.type)).toEqual([

+ 0 - 1
packages/ui/tui/tests/harness.ts

@@ -225,7 +225,6 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
       injectedOptions.push(input)
       return input.id
     },
-    reserveTurnAdmission: () => undefined,
     cancel(cause) {
       cancelled.push(cause)
     },

+ 11 - 25
packages/ui/tui/tests/tui.snapshot.ts

@@ -382,7 +382,7 @@ describe('TUI terminal-state snapshots', () => {
           ],
         })
         session.append('step/end', { turn: 1, step: 1 })
-        session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+        session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
         session.append('turn/start', {
           turn: 2,
         })
@@ -429,7 +429,7 @@ describe('TUI terminal-state snapshots', () => {
         },
       }),
     }, { surfaceOp: 'append' })
-    harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'completed' } })
     await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true })
     await disposeSnapshot(harness)
   })
@@ -448,9 +448,7 @@ describe('TUI terminal-state snapshots', () => {
         delayMs: 1_000,
         failure: { message: 'temporary transport failure', code: 'TRANSPORT' },
       })
-      harness.session.append('turn/end', {
-        turn: 1,
-        reason: { kind: 'aborted', reason: { kind: 'user' } },
+      harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
       })
     })
     await checkpoint('retry-cancelled', harness.terminal, { includeScrollback: true })
@@ -466,12 +464,8 @@ describe('TUI terminal-state snapshots', () => {
         step: 3,
         chunk: { type: 'text-delta', index: 0, text: 'discarded terminal partial output' },
       })
-      harness.session.append('turn/end', {
-        turn: 1,
-        reason: {
-          kind: 'error',
-          error: { message: 'provider still unavailable', code: 'SERVER', status: 503 },
-        },
+      harness.session.append('turn/end', { turn: 1, step: 3, reason: { kind: 'error', error: { message: 'provider still unavailable', code: 'SERVER', status: 503 },
+      },
       })
     })
     await checkpoint('retry-exhausted', harness.terminal, { includeScrollback: true })
@@ -647,9 +641,7 @@ describe('TUI terminal-state snapshots', () => {
           todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
         })
         session.append('step/end', { turn: 1, step: 1 })
-        session.append('turn/end', {
-          turn: 1,
-          reason: { kind: 'error', error: `Unsafe turn error ${CONTROL_PROBE}` },
+        session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: `Unsafe turn error ${CONTROL_PROBE}` },
         })
       },
     }, { columns: 100, rows: 34 })
@@ -809,24 +801,18 @@ describe('TUI terminal-state snapshots', () => {
       harness.terminal.send('\r')
       agentEvents(harness.ctx, harness.agent).emit('agent/error', 1, 1, new Error('provider stream failed after partial output'))
       harness.session.append('step/end', { turn: 1, step: 1 })
-      harness.session.append('turn/end', {
-        turn: 1,
-        reason: { kind: 'error', error: 'provider stream failed after partial output' },
+      harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'provider stream failed after partial output' },
       })
       harness.session.append('turn/start', { turn: 2 })
-      harness.session.append('turn/end', {
-        turn: 2,
-        reason: { kind: 'interrupted' },
+      harness.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'interrupted' },
       })
       harness.session.append('turn/start', { turn: 3 })
-      harness.session.append('turn/end', {
-        turn: 3,
-        reason: { kind: 'aborted', reason: { kind: 'disposed' } },
+      harness.session.append('turn/end', { turn: 3, step: 0, reason: { kind: 'aborted', reason: { kind: 'disposed' } },
       })
       harness.session.append('turn/start', { turn: 4 })
       // A merge-extensible turn-end kind unknown to the TUI still surfaces its
       // name so the agent never stops without a visible reason.
-      harness.session.append('turn/end', { turn: 4, reason: { kind: 'plugin-policy' } as never })
+      harness.session.append('turn/end', { turn: 4, step: 0, reason: { kind: 'plugin-policy' } as never })
     })
     await checkpoint('errors-and-help', harness.terminal, { includeScrollback: true })
 
@@ -877,7 +863,7 @@ describe('TUI terminal-state snapshots', () => {
           }),
         }, surfaceOp: 'append' },
         { type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } },
-        { type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, reason: { kind: 'completed' } } },
+        { type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
         { type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
         // A prior pickup, dated well after the work: the picker must still
         // show the work's date, not the pickup's.

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini