Bladeren bron

fix(agent-loop): mark finalized prefixes durable-interrupted; never finalize failed attempts

Review round findings:

- Clear the streaming attempt before the request-error waterfall: a cancel
  landing during recovery (typically the llm/retry backoff, after clients
  reset the streamed rendering) must not resurrect the failed stream's
  prefix. Provider failures commit nothing, now including that window.
- Record interrupted: true on the cancellation-finalized assistant/message.
  The chat projection keeps the settled prefix classified as interrupted
  (Stopped chip, restored web goldens), and request inspection leaves the
  request uncompleted so the step boundary classifies it as before.
- Pin the recovery-window and retry-discard semantics with content-bearing
  failed streams in cancel.spec; update the ACP late-end expectation to the
  finalized-prefix transcript.
- Mention interruptedBlocks() in the assembler stream-lifecycle JSDoc.
creatixchu 1 maand geleden
bovenliggende
commit
87f24bb991
31 gewijzigde bestanden met toevoegingen van 186 en 66 verwijderingen
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md
  4. 2 2
      apps/web/tests/snapshots/live-interactions/cancel.expected.md
  5. 2 2
      apps/web/tests/snapshots/queue-actions/preserved.expected.md
  6. 2 2
      docs/persistence-catalog.i18n.yaml
  7. 12 11
      docs/persistence-catalog.md
  8. 12 11
      docs/persistence-catalog.zh.md
  9. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  10. 2 1
      docs/subsystems/llm-streaming.md
  11. 2 1
      docs/subsystems/llm-streaming.zh.md
  12. 2 2
      docs/subsystems/session.i18n.yaml
  13. 4 3
      docs/subsystems/session.md
  14. 4 3
      docs/subsystems/session.zh.md
  15. 1 1
      examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
  16. 1 1
      examples/acp-agent/tests/snapshots/cancel/session.jsonl
  17. 0 0
      examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
  18. 2 2
      examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
  19. 2 2
      examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
  20. 2 2
      examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
  21. 3 3
      examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
  22. 3 1
      packages/acp/acp/tests/turns.spec.ts
  23. 4 1
      packages/client/runtime/src/client/sessions/request-inspection.ts
  24. 25 0
      packages/client/runtime/tests/request-inspection.spec.ts
  25. 4 0
      packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts
  26. 16 0
      packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts
  27. 6 1
      packages/core/agent-loop/src/agent.ts
  28. 58 1
      packages/core/agent-loop/tests/cancel.spec.ts
  29. 4 3
      packages/core/session/src/types.ts
  30. 2 1
      packages/llm/llm/src/assembler.ts
  31. 1 1
      packages/self-modification/tool-cordis/src/api-catalog.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
-2026-08-10-cancelled-stream-prefix-finalize.md: a2201cd17fd89744590bd4024bff80f3e3835e39
-2026-08-10-cancelled-stream-prefix-finalize.zh.md: 08abb5c097bdf3c6c21e0a96873ab4a995e8f3e9
+2026-08-10-cancelled-stream-prefix-finalize.md: 34b68ff30e399d75b2ad4417bd3e2b8c25cd5757
+2026-08-10-cancelled-stream-prefix-finalize.zh.md: febd96f8179433fb7411eed6c533e95bcfad2648

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md

@@ -12,7 +12,7 @@ The governing principle this violated: what the user can see, the next model req
 
 ## Decision
 
-`Agent.step()` keeps the current streaming attempt (assembler, logged chunk seqs, provider route) alive across the request loop. When an abort escapes the step while an attempt is uncommitted, `appendInterruptedAssistant` finalizes the attempt's user-visible prefix as the step's ordinary `assistant/message` — `surfaceOp: 'append'`, `sourceEventSeqs` citing exactly the logged chunks — before the abort continues to the `step/end`/`turn/end` teardown. A retry decision clears the attempt first: `llm/retry` resets what clients render, so an abort after it finalizes nothing from the abandoned attempt.
+`Agent.step()` keeps the current streaming attempt (assembler, logged chunk seqs, provider route) alive across the request loop. When an abort escapes the step while an attempt is uncommitted, `appendInterruptedAssistant` finalizes the attempt's user-visible prefix as the step's `assistant/message` with `interrupted: true` — `surfaceOp: 'append'`, `sourceEventSeqs` citing exactly the logged chunks — before the abort continues to the `step/end`/`turn/end` teardown. The durable marker is the classification consumers read: the chat projection keeps rendering the settled prefix as interrupted (the Stopped chip), and request inspection leaves the request uncompleted so the step boundary classifies it as before. An attempt that ends in an `error`/`aborted` finish is cleared before the recovery waterfall runs: provider failures commit nothing, and a cancel landing during recovery (typically the `llm/retry` backoff, after clients reset the streamed rendering) must not resurrect the failed stream's prefix.
 
 `BlockAssembler.interruptedBlocks()` owns what is safe to finalize, next to the existing max-tokens truncation rule: closed and open `text`/`reasoning` blocks with non-whitespace content, in stream order. Tool calls are dropped whole — interruption precedes dispatch, so a kept call would demand a fabricated result — as are empty blocks and open blocks of unknown type. When nothing survives, no event is appended and the turn keeps its previous shape: chunks, `step/end`, `turn/end` aborted.
 
@@ -30,7 +30,7 @@ Cancellation during tool execution is untouched: the tool-call message was alrea
 
 ## Consequences
 
-The surface now contains what the user saw at the moment of cancellation, so post-cancel follow-ups and forks connect. The cancel and goal snapshot fixtures record the finalized prefix event, and the ACP bridge forwards it as a final `agent_message_chunk` update after the cancelled stop reason. An interrupted step's `assistant/message` can now carry a mid-sentence prefix; consumers reading the aborted `turn/end` can classify it. Terminal provider errors keep the old behavior — their streamed prefix still vanishes from the surface — an asymmetry deliberately left for a follow-up decision because error turns end without the user choosing to stop.
+The surface now contains what the user saw at the moment of cancellation, so post-cancel follow-ups and forks connect. The cancel and goal snapshot fixtures record the finalized prefix event, and the ACP bridge forwards it as a final `agent_message_chunk` update after the cancelled stop reason — prompt settlement does not wait on loop teardown, so automation clients may receive the update after the cancelled stop reason. An interrupted step's `assistant/message` carries a mid-sentence prefix and the `interrupted: true` marker that classifies it. Terminal provider errors keep the old behavior — their streamed prefix still vanishes from the surface — an asymmetry deliberately left for a follow-up decision because error turns end without the user choosing to stop.
 
 ## Testing
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## Decision
 
-`Agent.step()` 让当前流式尝试(assembler、已记录的分片 seq、提供方路由)在请求循环之间保持存活。当 abort 在尝试未提交时逃出 step,`appendInterruptedAssistant` 会在 abort 继续走向 `step/end`/`turn/end` 收尾之前,把该尝试的用户可见前缀定稿为该 step 的普通 `assistant/message`,`surfaceOp: 'append'`,`sourceEventSeqs` 恰好引用已记录的分片。重试决定会先清空尝试:`llm/retry` 会重置客户端渲染的内容,因此其后的 abort 不会从被放弃的尝试中定稿任何东西
+`Agent.step()` 让当前流式尝试(assembler、已记录的分片 seq、提供方路由)在请求循环之间保持存活。当 abort 在尝试未提交时逃出 step,`appendInterruptedAssistant` 会在 abort 继续走向 `step/end`/`turn/end` 收尾之前,把该尝试的用户可见前缀定稿为该 step 的带 `interrupted: true` 的 `assistant/message`,`surfaceOp: 'append'`,`sourceEventSeqs` 恰好引用已记录的分片。这个持久标记就是消费者读取的分类:chat 投影继续把定稿前缀渲染为被打断(Stopped 徽章),请求检查让该请求保持未完成,由 step 边界照旧归类。以 `error`/`aborted` finish 结束的尝试会在恢复 waterfall 运行前被清空:提供方故障不提交任何内容,落在恢复期间的取消(典型是 `llm/retry` 退避期,此时客户端已重置流式渲染)不得复活失败流的前缀
 
 `BlockAssembler.interruptedBlocks()` 拥有「什么可以安全定稿」的规则,与既有的 max-tokens 截断规则放在一起:按流顺序保留内容非空白的已闭合与未闭合 `text`/`reasoning` 块。工具调用整块丢弃,因为打断先于分派,保留的调用会要求捏造一个结果;空块和未知类型的未闭合块同样丢弃。没有内容存活时不追加任何事件,轮次保持原有形状:分片、`step/end`、`turn/end` aborted。
 
@@ -30,7 +30,7 @@ Status: implemented
 
 ## Consequences
 
-surface 现在包含取消瞬间用户看到的内容,取消后的追问和 fork 都能接上。cancel 与 goal 两组快照 fixture 记录了定稿前缀事件,ACP 桥在 cancelled stop reason 之后把它作为最后一条 `agent_message_chunk` 更新转发。被打断 step 的 `assistant/message` 现在可能带着一个中途截断的前缀;消费者读到 aborted 的 `turn/end` 即可归类。终局提供方错误保持旧行为,其已流出前缀仍会从 surface 消失,这个不对称是有意留给后续决定的,因为 error 轮次的结束不是用户主动选择的停止。
+surface 现在包含取消瞬间用户看到的内容,取消后的追问和 fork 都能接上。cancel 与 goal 两组快照 fixture 记录了定稿前缀事件,ACP 桥在 cancelled stop reason 之后把它作为最后一条 `agent_message_chunk` 更新转发,prompt 的结算不等待循环收尾,因此自动化客户端可能在 cancelled stop reason 之后才收到该更新。被打断 step 的 `assistant/message` 带着中途截断的前缀和用于归类的 `interrupted: true` 标记。终局提供方错误保持旧行为,其已流出前缀仍会从 surface 消失,这个不对称是有意留给后续决定的,因为 error 轮次的结束不是用户主动选择的停止。
 
 ## Testing
 

+ 2 - 2
apps/web/tests/snapshots/live-interactions/cancel.expected.md

@@ -19,7 +19,7 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}}
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
 - textbox "Message the agent"
 - button "Commands":
   - img
@@ -28,4 +28,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
+- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} Input 0 tok · Output 0 tok

+ 2 - 2
apps/web/tests/snapshots/queue-actions/preserved.expected.md

@@ -19,7 +19,7 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}}
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
 - button "2 queued messages" [expanded]
 - list:
   - listitem:
@@ -47,4 +47,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
+- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} Input 0 tok · Output 0 tok

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 96374f4488dbfd4e60da99532ae2e559a3855c0f
-persistence-catalog.zh.md: c39e5db3deb01305ae687b79e2ee252f29bca55d
+persistence-catalog.md: 09f440514335fdc24b9abd036c448818aa8a4465
+persistence-catalog.zh.md: 6fe6b46375d30505fcea742c31d5cf6701491c95

+ 12 - 11
docs/persistence-catalog.md

@@ -79,7 +79,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }[T]
 ```
 
-Sources: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:387`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:388`](../packages/core/session/src/types.ts)
 
 ## Events
 
@@ -203,15 +203,16 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/
  * the model output and its accounting travel together (there is no separate
  * usage record). `usage` is absent when the adapter reported none. A turn
  * cancelled mid-stream finalizes its delivered text/reasoning prefix as this
- * event (undispatched tool calls are absent); an aborted turn with no such
- * event streamed no visible content.
+ * event with `interrupted: true` (undispatched tool calls are absent), so
+ * consumers classify the truncation without re-deriving it from turn
+ * boundaries; an aborted turn with no such event streamed no visible content.
  */
-'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage }
+'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
 ```
 
 Types: [TokenUsage](subsystems/llm-streaming.md)
 
-Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts)
 
 ### `command/*`
 
@@ -491,7 +492,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s
 'request/context': RequestContext
 ```
 
-Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:293`](../packages/core/session/src/types.ts)
 
 #### `request/header` — log-only
 
@@ -503,7 +504,7 @@ Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/
 'request/header': { header: EpochHeader; reason: RequestHeaderReason }
 ```
 
-Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts)
 
 ### `sandbox/*`
 
@@ -556,7 +557,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
 'session/end-seed': Record<string, never>
 ```
 
-Source: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts)
 
 #### `session/title` — log-only
 
@@ -631,7 +632,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent
 
 Types: [TodoItem](subsystems/session.md)
 
-Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
 
 ### `tool/*`
 
@@ -648,7 +649,7 @@ Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/
 
 Types: [CallId](subsystems/core.md)
 
-Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts)
 
 #### `tool/code-dispatch` — log-only
 
@@ -717,7 +718,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types
 }
 ```
 
-Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts)
 
 ### `turn/*`
 

+ 12 - 11
docs/persistence-catalog.zh.md

@@ -81,7 +81,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }[T]
 ```
 
-来源:[`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:387`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:388`](../packages/core/session/src/types.ts)
 
 ## 事件
 
@@ -205,15 +205,16 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
  * the model output and its accounting travel together (there is no separate
  * usage record). `usage` is absent when the adapter reported none. A turn
  * cancelled mid-stream finalizes its delivered text/reasoning prefix as this
- * event (undispatched tool calls are absent); an aborted turn with no such
- * event streamed no visible content.
+ * event with `interrupted: true` (undispatched tool calls are absent), so
+ * consumers classify the truncation without re-deriving it from turn
+ * boundaries; an aborted turn with no such event streamed no visible content.
  */
-'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage }
+'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
 ```
 
 类型:[TokenUsage](subsystems/llm-streaming.md)
 
-来源:[`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts)
 
 ### `command/*`
 
@@ -493,7 +494,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'request/context': RequestContext
 ```
 
-来源:[`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:293`](../packages/core/session/src/types.ts)
 
 #### `request/header` — log-only
 
@@ -505,7 +506,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'request/header': { header: EpochHeader; reason: RequestHeaderReason }
 ```
 
-来源:[`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts)
 
 ### `sandbox/*`
 
@@ -558,7 +559,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'session/end-seed': Record<string, never>
 ```
 
-来源:[`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts)
 
 #### `session/title` — log-only
 
@@ -633,7 +634,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[TodoItem](subsystems/session.md)
 
-来源:[`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
 
 ### `tool/*`
 
@@ -650,7 +651,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[CallId](subsystems/core.md)
 
-来源:[`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts)
 
 #### `tool/code-dispatch` — log-only
 
@@ -719,7 +720,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }
 ```
 
-来源:[`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts)
 
 ### `turn/*`
 

+ 2 - 2
docs/subsystems/llm-streaming.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
-llm-streaming.md: 93669b0781a16121765ffafb094ea14c9021e1ec
-llm-streaming.zh.md: bab98094f6a9ab3ca0aa9dc6178b46a612b205e8
+llm-streaming.md: 7ab12d8e26b5bedbde8a20e661b049336ed2a459
+llm-streaming.zh.md: f0fe8c1e724f7e58251a772da5c5fc9a1ff3d9e9

+ 2 - 1
docs/subsystems/llm-streaming.md

@@ -270,7 +270,8 @@ interface TokenUsage {
  * {@link ContentBlock}s and a final assistant {@link Message}.
  *
  * The agent loop feeds it while logging raw chunks for replay fidelity, then
- * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends.
+ * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends,
+ * or `interruptedBlocks()` when cancellation cut the stream short.
  *
  * Tolerant of delta-only protocols (no block-start/end); deltas arriving for
  * an index already closed by `block-end` are ignored (malformed stream) so a

+ 2 - 1
docs/subsystems/llm-streaming.zh.md

@@ -278,7 +278,8 @@ interface TokenUsage {
  * {@link ContentBlock}s and a final assistant {@link Message}.
  *
  * The agent loop feeds it while logging raw chunks for replay fidelity, then
- * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends.
+ * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends,
+ * or `interruptedBlocks()` when cancellation cut the stream short.
  *
  * Tolerant of delta-only protocols (no block-start/end); deltas arriving for
  * an index already closed by `block-end` are ignored (malformed stream) so a

+ 2 - 2
docs/subsystems/session.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session.md
-session.md: 3ffd7997afaf93633a034ffb8ebd308bd18e773d
-session.zh.md: 9a89f42c5a7f8bf0ac82ab0d9ca96be506d18876
+session.md: 7c26bbc1cfd197adf139c353c610ce2d951f0170
+session.zh.md: b05bbd52eec45fbd5520f1b6cc5e343b8e80950b

+ 4 - 3
docs/subsystems/session.md

@@ -61,10 +61,11 @@ interface SessionEventMap {
    * the model output and its accounting travel together (there is no separate
    * usage record). `usage` is absent when the adapter reported none. A turn
    * cancelled mid-stream finalizes its delivered text/reasoning prefix as this
-   * event (undispatched tool calls are absent); an aborted turn with no such
-   * event streamed no visible content.
+   * event with `interrupted: true` (undispatched tool calls are absent), so
+   * consumers classify the truncation without re-deriving it from turn
+   * boundaries; an aborted turn with no such event streamed no visible content.
    */
-  'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage }
+  'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
   /**
    * The model requested one tool invocation: `name` with the raw `arguments`
    * JSON string exactly as the model produced it (unparsed). `callId` pairs the

+ 4 - 3
docs/subsystems/session.zh.md

@@ -61,10 +61,11 @@ interface SessionEventMap {
    * the model output and its accounting travel together (there is no separate
    * usage record). `usage` is absent when the adapter reported none. A turn
    * cancelled mid-stream finalizes its delivered text/reasoning prefix as this
-   * event (undispatched tool calls are absent); an aborted turn with no such
-   * event streamed no visible content.
+   * event with `interrupted: true` (undispatched tool calls are absent), so
+   * consumers classify the truncation without re-deriving it from turn
+   * boundaries; an aborted turn with no such event streamed no visible content.
    */
-  'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage }
+  'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
   /**
    * The model requested one tool invocation: `name` with the raw `arguments`
    * JSON string exactly as the model produced it (unparsed). `callId` pairs the

+ 1 - 1
examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl

@@ -57,7 +57,7 @@
 {"type":"user/message","seq":55,"time":0,"data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n</goal_round>"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
 {"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}}
-{"type":"assistant/message","seq":58,"time":0,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[56,57],"surfaceOp":"append"}
+{"type":"assistant/message","seq":58,"time":0,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true},"sourceEventSeqs":[56,57],"surfaceOp":"append"}
 {"type":"step/end","seq":59,"time":0,"data":{"turn":3,"step":1}}
 {"type":"turn/end","seq":60,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}}
 {"type":"goal/change","seq":61,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}}

+ 1 - 1
examples/acp-agent/tests/snapshots/cancel/session.jsonl

@@ -10,6 +10,6 @@
 {"type":"request/context","seq":8,"time":1785730444532,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
 {"type":"assistant/chunk","seq":9,"time":1785498791456,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","seq":10,"time":1785730444541,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}}
-{"type":"assistant/message","seq":11,"time":1786334791338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"104e9294-f9b8-4248-b7df-0b7e2a069c0a"}},"sourceEventSeqs":[9,10],"surfaceOp":"append"}
+{"type":"assistant/message","seq":11,"time":1786334791338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"104e9294-f9b8-4248-b7df-0b7e2a069c0a"},"interrupted":true},"sourceEventSeqs":[9,10],"surfaceOp":"append"}
 {"type":"step/end","seq":12,"time":1786334791338,"data":{"turn":1,"step":1}}
 {"type":"turn/end","seq":13,"time":1786334791338,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}}

File diff suppressed because it is too large
+ 0 - 0
examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl


File diff suppressed because it is too large
+ 2 - 2
examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl


File diff suppressed because it is too large
+ 2 - 2
examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl


File diff suppressed because it is too large
+ 2 - 2
examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl


File diff suppressed because it is too large
+ 3 - 3
examples/headless-agent/tests/snapshots/pty-tools/session.jsonl


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

@@ -228,7 +228,9 @@ describe('ACP prompt lifecycle', () => {
 
     await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
       .resolves.toEqual({ stopReason: 'end_turn' })
-    await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') })
+    // 'partial' is the cancelled turn's finalized prefix update; 'next' proves
+    // the second prompt settled independently of the aborted turn's late end.
+    await vi.waitFor(() => { expect(messageText(harness!)).toBe('partialnext') })
   })
 
   it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => {

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

@@ -283,7 +283,10 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
       const request = index === undefined ? undefined : requests[index]
       updateAssistant(index, {
         completedAt: sourceEvent.time,
-        status: 'complete',
+        // A cancellation-finalized prefix is not a completed request: leave it
+        // running so the following step/end classifies it as before the prefix
+        // event existed.
+        status: sourceEvent.data.interrupted === true ? 'running' : 'complete',
         resultSeq: sourceEvent.seq,
         provenance: {
           provider: sourceEvent.data.message.source.provider,

+ 25 - 0
packages/client/runtime/tests/request-inspection.spec.ts

@@ -85,6 +85,31 @@ describe('inspectRequests', () => {
     expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
   })
 
+  it('leaves a cancellation-finalized prefix uncompleted so the step boundary classifies it', () => {
+    const events = [
+      at(0, 'step/start', { turn: 1, step: 1 }),
+      at(1, 'request/header', {
+        reason: 'initial',
+        header: { config: { provider: 'fake', model: 'model' }, system: 'system' },
+      }),
+      at(2, 'assistant/message', {
+        turn: 1,
+        step: 1,
+        message: createAssistantMessage({
+          content: [{ type: 'text', text: 'cut short' }],
+          source: { provider: 'fake', model: 'model' },
+        }),
+        interrupted: true,
+      }),
+      at(3, 'step/end', { turn: 1, step: 1 }),
+      at(4, 'turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }),
+    ]
+    const snapshot = inspectRequests(entriesOf(events))
+    expect(snapshot.requests).toMatchObject([
+      { purpose: 'assistant', resultSeq: 2, status: 'error' },
+    ])
+  })
+
   it('does not promote a truncated resume or change header to the initial prompt', () => {
     for (const reason of ['resume', 'change'] as const) {
       const snapshot = inspectRequests(entriesOf([

+ 4 - 0
packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts

@@ -162,6 +162,10 @@ function finalNode(
         firstTokenTime: state.firstTokenTime ?? null,
         completedTime: event.time,
       },
+      // A cancellation-finalized prefix keeps its truncation marker: the
+      // durable event carries the classification, so the settled node still
+      // renders as interrupted (Stopped chip) rather than a completed answer.
+      ...event.data.interrupted === true ? { interrupted: true } : {},
     }
   }
   const location = context.start?.location ?? context.matches.at(-1)?.location

+ 16 - 0
packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts

@@ -158,6 +158,22 @@ describe('built-in conversation node Definitions', () => {
     expect(interrupted?.data).toMatchObject({ status: 'interrupted' })
     expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
 
+    // A cancellation-finalized prefix event keeps its durable truncation
+    // marker through the settled projection.
+    const markedValue = assembler([
+      at(20, 'turn/start', { turn: 3 }),
+      at(21, 'step/start', { turn: 3, step: 1 }),
+      at(22, 'assistant/message', {
+        turn: 3,
+        step: 1,
+        message: assistantMessage('assistant-3', 'cut short'),
+        interrupted: true,
+      }, { surfaceOp: 'append' }),
+    ])
+    const marked = node(snapshot(markedValue), 'assistant-step')
+    expect(marked?.data).toMatchObject({ status: 'interrupted', blocks: [{ kind: 'text', text: 'cut short' }] })
+    expect((marked?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
+
     const hiddenValue = assembler([
       at(20, 'turn/start', { turn: 3 }),
       at(21, 'step/start', { turn: 3, step: 1 }),

+ 6 - 1
packages/core/agent-loop/src/agent.ts

@@ -367,6 +367,11 @@ export class ReactLoopAgent implements Agent {
         signal.throwIfAborted()
         const finish = assembler.finish
         if (finish.kind === 'error' || finish.kind === 'aborted') {
+          // A failed attempt is never finalizable: provider failures commit
+          // nothing, and a cancel landing during recovery (typically the
+          // llm/retry backoff, after clients reset the streamed rendering)
+          // must not resurrect the failed stream's prefix.
+          attempt = undefined
           const action = await this.dispatch.waterfall(
             'agent/request-error', {
               turn,
@@ -382,7 +387,6 @@ export class ReactLoopAgent implements Agent {
           if (action?.kind !== 'retry') {
             throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
           }
-          attempt = undefined
           continue
         }
 
@@ -444,6 +448,7 @@ export class ReactLoopAgent implements Agent {
         turn,
         step,
         message,
+        interrupted: true,
         ...attempt.assembler.usage === undefined ? {} : { usage: attempt.assembler.usage },
       },
       { surfaceOp: 'append', sourceEventSeqs: attempt.chunkSeqs },

+ 58 - 1
packages/core/agent-loop/tests/cancel.spec.ts

@@ -490,10 +490,12 @@ describe('Agent.cancel()', () => {
     await waitForIdle(ctx, agent)
 
     // The prefix the user watched stream is committed as the step's message,
-    // citing exactly the chunk events that delivered it.
+    // carrying the truncation marker and citing exactly the chunk events that
+    // delivered it.
     const message = agent.session.events.find(e => e.type === 'assistant/message')
     expect(message?.type === 'assistant/message' ? message.data.message.content : undefined)
       .toEqual([{ type: 'text', text: 'partial' }])
+    expect(message?.type === 'assistant/message' ? message.data.interrupted : undefined).toBe(true)
     const chunkSeqs = agent.session.events.filter(e => e.type === 'assistant/chunk').map(e => e.seq)
     expect(message?.sourceEventSeqs).toEqual(chunkSeqs)
     const types = agent.session.events.map(e => e.type)
@@ -559,6 +561,61 @@ describe('Agent.cancel()', () => {
     expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
   })
 
+  it('cancel during error recovery does not finalize the failed stream', async () => {
+    const adapter = new MockAdapter([[
+      { type: 'block-start', index: 0, blockType: 'text' },
+      { type: 'text-delta', index: 0, text: 'doomed partial' },
+      { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'SERVER_ERROR' } } },
+    ]])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(SessionId('recovery-cancel'), { provider: 'mock', model: 'mock' })
+    // Cancellation lands while agent/request-error is in flight — the window
+    // dsh-llm-retry opens when its backoff waits after appending llm/retry.
+    ctx.on('agent/request-error', async ({ agent: subject }) => {
+      if (subject === agent) subject.cancel({ kind: 'user' })
+    })
+
+    send(agent, 'go')
+    await waitForIdle(ctx, agent)
+
+    // The failed stream's prefix stays off the surface: clients reset it on
+    // retry, and provider failures commit nothing.
+    expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false)
+    const end = agent.session.events.find(e => e.type === 'turn/end')
+    expect(end?.type === 'turn/end' ? end.data.reason.kind : undefined).toBe('aborted')
+  })
+
+  it('retry discards the failed attempt; the final message cites only its own chunks', async () => {
+    const adapter = new MockAdapter([
+      [
+        { type: 'block-start', index: 0, blockType: 'text' },
+        { type: 'text-delta', index: 0, text: 'doomed partial' },
+        { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'SERVER_ERROR' } } },
+      ],
+      textResponse('recovered'),
+    ])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(SessionId('retry-discards-content'), { provider: 'mock', model: 'mock' })
+    ctx.on('agent/request-error', async () => ({ kind: 'retry' as const }))
+
+    send(agent, 'go')
+    await waitForIdle(ctx, agent)
+
+    const messages = agent.session.events.filter(e => e.type === 'assistant/message')
+    expect(messages).toHaveLength(1)
+    const message = messages[0]!
+    expect(message.type === 'assistant/message' ? message.data.message.content : undefined)
+      .toEqual([{ type: 'text', text: 'recovered' }])
+    expect(message.type === 'assistant/message' ? message.data.interrupted : undefined).toBeUndefined()
+    // The abandoned attempt's chunks stay out of the completion's source set.
+    const doomedSeqs = agent.session.events
+      .filter(e => e.type === 'assistant/chunk'
+        && e.data.chunk.type === 'text-delta' && e.data.chunk.text === 'doomed partial')
+      .map(e => e.seq)
+    expect(doomedSeqs).toHaveLength(1)
+    expect(message.sourceEventSeqs).not.toContain(doomedSeqs[0])
+  })
+
   it('cancel before any visible content finalizes nothing', async () => {
     const adapter = new MockAdapter([{
       hangAfter: [

+ 4 - 3
packages/core/session/src/types.ts

@@ -250,10 +250,11 @@ export interface SessionEventMap {
    * the model output and its accounting travel together (there is no separate
    * usage record). `usage` is absent when the adapter reported none. A turn
    * cancelled mid-stream finalizes its delivered text/reasoning prefix as this
-   * event (undispatched tool calls are absent); an aborted turn with no such
-   * event streamed no visible content.
+   * event with `interrupted: true` (undispatched tool calls are absent), so
+   * consumers classify the truncation without re-deriving it from turn
+   * boundaries; an aborted turn with no such event streamed no visible content.
    */
-  'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage }
+  'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
   /**
    * The model requested one tool invocation: `name` with the raw `arguments`
    * JSON string exactly as the model produced it (unparsed). `callId` pairs the

+ 2 - 1
packages/llm/llm/src/assembler.ts

@@ -27,7 +27,8 @@ interface PartialBlock {
  * {@link ContentBlock}s and a final assistant {@link Message}.
  *
  * The agent loop feeds it while logging raw chunks for replay fidelity, then
- * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends.
+ * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends,
+ * or `interruptedBlocks()` when cancellation cut the stream short.
  *
  * Tolerant of delta-only protocols (no block-start/end); deltas arriving for
  * an index already closed by `block-end` are ignored (malformed stream) so a

+ 1 - 1
packages/self-modification/tool-cordis/src/api-catalog.ts

@@ -2583,7 +2583,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    \'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        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        interrupted?: true;\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    \'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',

Some files were not shown because too many files changed in this diff