Explorar o código

Merge pull request #2134 from deepseek-harness/worktree/abort-partial-finalize

feat(agent-loop): finalize a cancelled stream's delivered prefix
CreatixChu hai 3 semanas
pai
achega
24ef32c7b8
Modificáronse 41 ficheiros con 508 adicións e 73 borrados
  1. 6 0
      .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml
  2. 39 0
      .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
  3. 39 0
      .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md
  4. 6 2
      apps/web/tests/snapshots/live-interactions/cancel.expected.md
  5. 6 2
      apps/web/tests/snapshots/queue-actions/preserved.expected.md
  6. 4 0
      apps/web/tests/snapshots/turn-tail-actions/settled.expected.md
  7. 2 2
      docs/persistence-catalog.i18n.yaml
  8. 14 10
      docs/persistence-catalog.md
  9. 14 10
      docs/persistence-catalog.zh.md
  10. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  11. 10 1
      docs/subsystems/llm-streaming.md
  12. 10 1
      docs/subsystems/llm-streaming.zh.md
  13. 2 2
      docs/subsystems/session.i18n.yaml
  14. 6 2
      docs/subsystems/session.md
  15. 6 2
      docs/subsystems/session.zh.md
  16. 4 3
      examples/acp-agent/tests/goal-snapshots/goal-round-driver/session.expected.jsonl
  17. 1 0
      examples/acp-agent/tests/goal-snapshots/goal-round-driver/stdout.expected.jsonl
  18. 3 2
      examples/acp-agent/tests/snapshots/cancel/session.jsonl
  19. 1 0
      examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl
  20. 3 1
      packages/acp/acp/tests/turns.spec.ts
  21. 2 2
      packages/client/runtime/README.i18n.yaml
  22. 1 1
      packages/client/runtime/README.md
  23. 1 1
      packages/client/runtime/README.zh.md
  24. 7 6
      packages/client/runtime/src/client/sessions/conversation.ts
  25. 1 0
      packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts
  26. 14 0
      packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts
  27. 2 2
      packages/client/ui-trajectory/README.i18n.yaml
  28. 0 0
      packages/client/ui-trajectory/README.md
  29. 0 0
      packages/client/ui-trajectory/README.zh.md
  30. 2 1
      packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts
  31. 32 0
      packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
  32. 2 2
      packages/core/agent-loop/README.i18n.yaml
  33. 1 1
      packages/core/agent-loop/README.md
  34. 1 1
      packages/core/agent-loop/README.zh.md
  35. 25 6
      packages/core/agent-loop/src/agent.ts
  36. 156 1
      packages/core/agent-loop/tests/cancel.spec.ts
  37. 17 3
      packages/core/agent-loop/tests/mock-adapter.ts
  38. 6 2
      packages/core/session/src/types.ts
  39. 1 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  40. 21 1
      packages/llm/llm/src/assembler.ts
  41. 38 0
      packages/llm/llm/tests/assembler.spec.ts

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: 0cae25b786922fba8204d68ca9c0a669e43d76a0
+2026-08-10-cancelled-stream-prefix-finalize.zh.md: e961ea6a51f74dcc244e4ad8970eae4cbe4c9a6c

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

@@ -0,0 +1,39 @@
+# Agent Note: Cancelled streams finalize their delivered prefix
+
+Status: implemented
+
+English | [中文](2026-08-10-cancelled-stream-prefix-finalize.zh.md)
+
+## Problem
+
+A cancelled stream can leave `assistant/chunk` events that clients continue rendering while `deriveMessages()` excludes them because no `assistant/message` records the delivered prefix. A follow-up such as "expand on your second point" then lacks text the user read, and a fork at the cancelled turn inherits the same gap.
+
+The model history must contain assistant content that remains visible to the user after cancellation.
+
+## Decision
+
+`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, logged chunk seqs, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`.
+
+`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. Provider `error` and `aborted` finishes leave the stream-consumption scope before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed request.
+
+Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provenance. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results.
+
+## Alternatives considered
+
+**Always discard the prefix.** This avoids a new durable marker but makes every cancel-then-follow-up and fork omit assistant content that remains visible to the user.
+
+**Assemble the prefix from chunks during projection.** `deriveMessages()` and client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative assistant message for the prefix. This also expands model history beyond the three `SurfaceEventType` events.
+
+**Retain complete tool calls with synthetic aborted results.** These calls never dispatched, so synthetic results would claim an execution outcome that did not occur and add content the user did not receive as a tool result.
+
+**Append a model-visible interruption message such as `[interrupted by user]`.** This can tell the model that the prefix is incomplete, but it requires a separate source type, projection rule, UI treatment, and localized wording. The durable aborted `turn/end` preserves the fact needed for that later decision.
+
+## Consequences
+
+Post-cancel follow-ups and forks include the delivered prefix. The ACP bridge drains ordered assistant output before settling the prompt, so the final `agent_message_chunk` update precedes the cancelled stop reason.
+
+Terminal provider errors still discard their streamed prefix. That asymmetry remains because an error turn ends without the user's cancellation decision and requires its own retention policy.
+
+## Testing
+
+`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications.

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

@@ -0,0 +1,39 @@
+# Agent Note: 被取消的流定稿其已送达前缀
+
+Status: implemented
+
+[English](2026-08-10-cancelled-stream-prefix-finalize.md) | 中文
+
+## Problem
+
+被取消的流可能留下客户端继续渲染的 `assistant/chunk` 事件,但如果没有 `assistant/message` 记录已送达前缀,`deriveMessages()` 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。
+
+模型历史必须包含取消后仍对用户可见的 assistant 内容。
+
+## Decision
+
+`ReactLoopAgent.step()` 在消费模型流期间捕捉取消,此时 `BlockAssembler`、已记录的分片 seq 和提供方路由可以确定已送达前缀。循环把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`。
+
+`BlockAssembler.interruptedBlocks()` 按流顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` 块。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时不追加 assistant 消息。提供方的 `error` 和 `aborted` finish 会在 `agent/request-error` 前离开流消费范围,因此提供方故障和恢复期间的取消都不会提交失败请求的内容。
+
+Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`。Chat 渲染 Stopped 标记,Trajectory 则在 `step/end` 后把提供方请求保持在 error 生命周期,并保留持久结果 seq 和提供方信息。工具执行期间的取消遵循工具调度器约定,因为 assistant 消息已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。
+
+## Alternatives considered
+
+**始终丢弃前缀。** 这能避免新增持久标记,但每次取消后的追问和分支都会缺少仍对用户可见的 assistant 内容。
+
+**在投影时从分片组装前缀。** `deriveMessages()` 和客户端 Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 assistant 消息。这还会让模型历史超出三类 `SurfaceEventType` 事件。
+
+**保留完整工具调用并合成 aborted 结果。** 这些调用从未分派,合成结果会声称一个并未发生的执行结果,还会增加用户未收到的工具结果内容。
+
+**追加 `[interrupted by user]` 之类模型可见的打断消息。** 这可以告诉模型前缀并不完整,但需要独立的来源类型、投影规则、UI 处理和本地化文案。持久的 aborted `turn/end` 保留了该后续决策所需的事实。
+
+## Consequences
+
+取消后的追问和分支会包含已送达前缀。ACP 桥会在结算 prompt 前排空按序传送的 assistant 输出,因此最后一条 `agent_message_chunk` 更新先于 cancelled stop reason。
+
+终局提供方错误仍会丢弃已流出前缀。该不对称保留,因为 error 轮次的结束不来自用户的取消决定,需要独立的保留策略。
+
+## Testing
+
+`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。

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

@@ -20,9 +20,13 @@
 - text: Stopped
 - button "Copy":
   - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - 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
@@ -31,4 +35,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps
+- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}}

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

@@ -20,9 +20,13 @@
 - text: Stopped
 - button "Copy":
   - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - 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:
@@ -50,4 +54,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps
+- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}}

+ 4 - 0
apps/web/tests/snapshots/turn-tail-actions/settled.expected.md

@@ -30,6 +30,10 @@
 - button "Copy":
   - img
 - tooltip "Copy"
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
 - button "Branch into a new conversation":
   - img
 - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

+ 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: b680bccf22f7840663e5268eb3feeb6b16f7fd42
-persistence-catalog.zh.md: 4b50582fa55dbdc672d8c45debe108b97a55f0e2
+persistence-catalog.md: cbe9cec195ad899ba27dd08743d64090ae7277f2
+persistence-catalog.zh.md: 8057a0910e505b95872c341283d1acdbf033faaf

+ 14 - 10
docs/persistence-catalog.md

@@ -90,7 +90,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }[T]
 ```
 
-Sources: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:372`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:340`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:408`](../packages/core/session/src/types.ts)
 
 ## Events
 
@@ -226,14 +226,18 @@ Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/
  * Assembled assistant message for one step (derived history uses this).
  * Carries the step's `usage` when the adapter reported token accounting, so
  * the model output and its accounting travel together (there is no separate
- * usage record). `usage` is absent when the adapter reported none.
+ * usage record). `usage` is absent when the adapter reported none. A turn
+ * cancelled mid-stream finalizes its delivered text/reasoning prefix as this
+ * event with `interrupted: true`; undispatched tool calls are absent. The
+ * marker distinguishes that prefix without re-deriving interruption 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:273`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts)
 
 ### `command/*`
 
@@ -543,7 +547,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/s
 'request/context': RequestContext
 ```
 
-Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts)
 
 <a id="requestheader--log-only"></a>
 
@@ -557,7 +561,7 @@ Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/
 'request/header': { header: EpochHeader; reason: RequestHeaderReason }
 ```
 
-Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts)
 
 ### `sandbox/*`
 
@@ -632,7 +636,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch
 'session/end-seed': Record<string, never>
 ```
 
-Source: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts)
 
 <a id="sessiontitle--log-only"></a>
 
@@ -778,7 +782,7 @@ Source: [`packages/experimental/team/src/types.ts:208`](../packages/experimental
 
 Types: [TodoItem](subsystems/session.md)
 
-Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts)
 
 ### `tool/*`
 
@@ -797,7 +801,7 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/
 
 Types: [CallId](subsystems/core.md)
 
-Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
 
 <a id="toolcode-dispatch--log-only"></a>
 
@@ -872,7 +876,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types
 }
 ```
 
-Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts)
 
 ### `tool-workflow/*`
 

+ 14 - 10
docs/persistence-catalog.zh.md

@@ -92,7 +92,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }[T]
 ```
 
-来源:[`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:372`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:340`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:408`](../packages/core/session/src/types.ts)
 
 ## 事件
 
@@ -228,14 +228,18 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
  * Assembled assistant message for one step (derived history uses this).
  * Carries the step's `usage` when the adapter reported token accounting, so
  * the model output and its accounting travel together (there is no separate
- * usage record). `usage` is absent when the adapter reported none.
+ * usage record). `usage` is absent when the adapter reported none. A turn
+ * cancelled mid-stream finalizes its delivered text/reasoning prefix as this
+ * event with `interrupted: true`; undispatched tool calls are absent. The
+ * marker distinguishes that prefix without re-deriving interruption 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:273`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts)
 
 ### `command/*`
 
@@ -545,7 +549,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'request/context': RequestContext
 ```
 
-来源:[`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts)
 
 <a id="requestheader--log-only"></a>
 
@@ -559,7 +563,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'request/header': { header: EpochHeader; reason: RequestHeaderReason }
 ```
 
-来源:[`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts)
 
 ### `sandbox/*`
 
@@ -634,7 +638,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'session/end-seed': Record<string, never>
 ```
 
-来源:[`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts)
 
 <a id="sessiontitle--log-only"></a>
 
@@ -780,7 +784,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[TodoItem](subsystems/session.md)
 
-来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts)
 
 ### `tool/*`
 
@@ -799,7 +803,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[CallId](subsystems/core.md)
 
-来源:[`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
 
 <a id="toolcode-dispatch--log-only"></a>
 
@@ -874,7 +878,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }
 ```
 
-来源:[`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts)
 
 ### `tool-workflow/*`
 

+ 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: c1b2ab5f1e0926864f25409c021691d078df9e0f
-llm-streaming.zh.md: 7bf04bf7a4a7de63d20eb67461842ff49f3b189d
+llm-streaming.md: 8731a535267bddd2bf99a06a2beee92d3083232d
+llm-streaming.zh.md: b9e71bba7428dafbbc7a366a97c2d7ddc9a87d46

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

@@ -298,7 +298,8 @@ One keep/drop decision covers content and metadata together: a `max-tokens` fini
  * {@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
@@ -317,6 +318,14 @@ declare class BlockAssembler {
    *   its accumulated deltas (an unknown block type never closed by `block-end` throws).
    */
   blocks(): ContentBlock[];
+  /**
+   * Assemble the prefix an interrupted stream can safely finalize: closed and
+   * open text/reasoning blocks with non-whitespace content, in stream order.
+   * Tool calls are omitted because interruption precedes dispatch; retaining
+   * one would require a fabricated result. Open unknown blocks are also omitted.
+   * @returns the kept blocks; empty when nothing streamed before the interruption.
+   */
+  interruptedBlocks(): ContentBlock[];
   /** Usage from the `usage` chunk; undefined until one arrives. */
   get usage(): TokenUsage | undefined;
   /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */

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

@@ -304,7 +304,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
@@ -323,6 +324,14 @@ declare class BlockAssembler {
    *   its accumulated deltas (an unknown block type never closed by `block-end` throws).
    */
   blocks(): ContentBlock[];
+  /**
+   * Assemble the prefix an interrupted stream can safely finalize: closed and
+   * open text/reasoning blocks with non-whitespace content, in stream order.
+   * Tool calls are omitted because interruption precedes dispatch; retaining
+   * one would require a fabricated result. Open unknown blocks are also omitted.
+   * @returns the kept blocks; empty when nothing streamed before the interruption.
+   */
+  interruptedBlocks(): ContentBlock[];
   /** Usage from the `usage` chunk; undefined until one arrives. */
   get usage(): TokenUsage | undefined;
   /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */

+ 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: aea9d00b38e384e7a973ce168c3a75a62e70a8bb
-session.zh.md: 8c56029af5144569f1ab6df73a8fe2278f9ef5b4
+session.md: 3dc8762468efd2f1515ae10e7400a7d85941b461
+session.zh.md: 3edbe86c3dea7dcfaa3c1fef6a5ca9e4a4b2c36a

+ 6 - 2
docs/subsystems/session.md

@@ -59,9 +59,13 @@ interface SessionEventMap {
    * Assembled assistant message for one step (derived history uses this).
    * Carries the step's `usage` when the adapter reported token accounting, so
    * the model output and its accounting travel together (there is no separate
-   * usage record). `usage` is absent when the adapter reported none.
+   * usage record). `usage` is absent when the adapter reported none. A turn
+   * cancelled mid-stream finalizes its delivered text/reasoning prefix as this
+   * event with `interrupted: true`; undispatched tool calls are absent. The
+   * marker distinguishes that prefix without re-deriving interruption 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

+ 6 - 2
docs/subsystems/session.zh.md

@@ -59,9 +59,13 @@ interface SessionEventMap {
    * Assembled assistant message for one step (derived history uses this).
    * Carries the step's `usage` when the adapter reported token accounting, so
    * the model output and its accounting travel together (there is no separate
-   * usage record). `usage` is absent when the adapter reported none.
+   * usage record). `usage` is absent when the adapter reported none. A turn
+   * cancelled mid-stream finalizes its delivered text/reasoning prefix as this
+   * event with `interrupted: true`; undispatched tool calls are absent. The
+   * marker distinguishes that prefix without re-deriving interruption 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
examples/acp-agent/tests/goal-snapshots/goal-round-driver/session.expected.jsonl

@@ -57,6 +57,7 @@
 {"type":"user/message","seq":55,"time":0,"data":{"content":[{"type":"text","text":"<goal_round>\nObjective: \"Finish the ACP goal-round-driver 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":"step/end","seq":58,"time":0,"data":{"turn":3,"step":1}}
-{"type":"turn/end","seq":59,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}}
-{"type":"goal/change","seq":60,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-round-driver snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}}
+{"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-round-driver snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}}

+ 1 - 0
examples/acp-agent/tests/goal-snapshots/goal-round-driver/stdout.expected.jsonl

@@ -3,3 +3,4 @@
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL READY"}}}}
 {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
 {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL ROUND ONE"}}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}}

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

@@ -10,5 +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":"step/end","seq":11,"time":1785730444547,"data":{"turn":1,"step":1}}
-{"type":"turn/end","seq":12,"time":1785730444547,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}}
+{"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"}}}}

+ 1 - 0
examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl

@@ -1,3 +1,4 @@
 {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
 {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}}
 {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}}

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

@@ -464,7 +464,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 () => {

+ 2 - 2
packages/client/runtime/README.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 packages/client/runtime/README.md
-README.md: 7d0e90809aaffb9503f2222e1f7426b13d72d7d5
-README.zh.md: d0a43c32b4861d02786aa0c42e1af72047c9032a
+README.md: eae49163c14f122a3603bcaa8a61aaac7a9a7270
+README.zh.md: 9e8f82a0bcf0d2021c9b16bd51acf3c72dd04cb2

+ 1 - 1
packages/client/runtime/README.md

@@ -56,7 +56,7 @@ The Chat builder keeps one mutable keyed store per Session. Content updates noti
 
 ## Trajectory request data
 
-Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
+Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A cancellation-finalized `assistant/message` retains its durable result seq and provider provenance but does not complete the request; `step/end` classifies that request as an error. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
 
 ## Code Mode child-call tree
 

+ 1 - 1
packages/client/runtime/README.zh.md

@@ -56,7 +56,7 @@ Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新
 
 ## Trajectory 请求数据
 
-Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
+Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。由取消定稿的 `assistant/message` 会保留持久结果 seq 和提供方信息,但不会将请求标记为完成;`step/end` 会把该请求归类为错误。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
 
 ## Code Mode 子调用树
 

+ 7 - 6
packages/client/runtime/src/client/sessions/conversation.ts

@@ -92,14 +92,14 @@ export interface AssistantTiming {
   completedTime: number
 }
 
-/** A finalized (or interruption-frozen) assistant message. */
+/** A finalized assistant message or an interruption-frozen streaming prefix. */
 export interface AssistantMessageNode {
   kind: 'assistant'
   seq: number
   /**
-   * Stable identity of the finalized model output, carried from the
-   * `assistant/message` event. Absent on interruption-frozen partials: those
-   * were never finalized, so they address no durable message.
+   * Stable identity carried from the `assistant/message` event. Absent only on
+   * synthetic interruption fallbacks assembled from chunks without a durable
+   * assistant message.
    */
   messageId?: MessageId
   /** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */
@@ -112,8 +112,9 @@ export interface AssistantMessageNode {
   requestConfig?: AssistantRequestConfig
   /** Timing derived from the recorded step/chunk/message event sequence. */
   timing?: AssistantTiming
-  /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
-   *  Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
+  /** Prefix of an aborted turn, rendered with a 已停止 marker. A durable
+   *  finalized prefix uses its event seq; a chunk-only fallback uses a fractional
+   *  seq derived from the closing boundary to keep it ordered inside the flow. */
   interrupted?: true
 }
 

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

@@ -163,6 +163,7 @@ function finalNode(
         firstTokenTime: state.firstTokenTime ?? null,
         completedTime: event.time,
       },
+      ...event.data.interrupted === true ? { interrupted: true } : {},
     }
   }
   const location = context.start?.location ?? context.matches.at(-1)?.location

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

@@ -160,6 +160,20 @@ describe('built-in conversation node Definitions', () => {
     expect(interrupted?.data).toMatchObject({ status: 'interrupted' })
     expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
 
+    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 }),

+ 2 - 2
packages/client/ui-trajectory/README.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 packages/client/ui-trajectory/README.md
-README.md: 9ad42a3d3bcfb8fbf4842a66577e65794a716dfd
-README.zh.md: df7661ef7d4861dbc6c8e365a18b3e3252d4d4af
+README.md: 13ccc97ae3cefbcddfb4f31cb290a3da314cc86e
+README.zh.md: 1f4f1393e296808d75ba7f092ee6caa4035dda2e

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/client/ui-trajectory/README.md


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/client/ui-trajectory/README.zh.md


+ 2 - 1
packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts

@@ -223,6 +223,7 @@ function finalNode(
         firstTokenTime: state.firstTokenTime ?? null,
         completedTime: event.time,
       },
+      ...(event.data.interrupted === true ? { interrupted: true } : {}),
     }
   }
   const boundary = closedBoundary(context)
@@ -264,7 +265,7 @@ function assistantRequest(
         ...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }),
         retryDelayMs: state.retry.delayMs,
       }),
-    ...(node === undefined || node.interrupted === true
+    ...(node?.messageId === undefined
       ? {}
       : {
         resultSeq: node.seq,

+ 32 - 0
packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts

@@ -149,6 +149,38 @@ describe('Trajectory conversation Definitions', () => {
     }])
   })
 
+  it('classifies a cancellation-finalized prefix as an interrupted request result', () => {
+    const current = snapshot(assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'step/start', { turn: 1, step: 1 }),
+      at(3, 'assistant/message', {
+        turn: 1,
+        step: 1,
+        message: assistantMessage('interrupted-message', 'cut short'),
+        interrupted: true,
+      }),
+      at(4, 'step/end', { turn: 1, step: 1 }),
+      at(5, 'turn/end', {
+        turn: 1,
+        reason: { kind: 'aborted', reason: { kind: 'user' } },
+      }),
+    ]))
+
+    expect(current.eventNodes).toMatchObject([{
+      kind: 'assistant',
+      seq: 3,
+      messageId: 'interrupted-message',
+      interrupted: true,
+      blocks: [{ kind: 'text', text: 'cut short' }],
+    }])
+    expect(current.requests).toMatchObject([{
+      purpose: 'assistant',
+      resultSeq: 3,
+      status: 'error',
+      provenance: { provider: 'test', model: 'test' },
+    }])
+  })
+
   it('keeps parallel interrupted roots and nests Code Dispatch results', () => {
     const current = snapshot(assembler([
       at(1, 'turn/start', { turn: 1 }),

+ 2 - 2
packages/core/agent-loop/README.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 packages/core/agent-loop/README.md
-README.md: 683799d1840c857981d4ff30dd3e8e078be03098
-README.zh.md: 8009f72a387f5c65ed258eed9ca6d1f14835c41f
+README.md: 907bbe75fd687388be044916f4c4509e1c552177
+README.zh.md: 29b06c907bb1a72c5056d29130fe85f479f2f19a

+ 1 - 1
packages/core/agent-loop/README.md

@@ -63,7 +63,7 @@ Every inbox mutation publishes one normalized `agent/inbox/spliced` event before
 
 The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Package-private orchestration entry points recover the exact Agent, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or per-operation `Session` through shallow interfaces. A helper keeps an explicit `Session` when that is its actual interface, while creation, persistence load, unpublished setup, services, workers, processes, persistence, and wire protocols retain their explicit identities. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules.
 
-Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, lists the exact chunk seqs in `sourceEventSeqs` (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history.
+Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, lists the exact chunk seqs in `sourceEventSeqs` (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. A turn cancellation that interrupts streaming also appends an `interrupted: true` anchor when non-empty text or reasoning has reached the user. The anchor cites those chunk seqs and places the rendered prefix in derived message history, so the next request contains what the user saw. Undispatched tool calls are omitted, and an empty or tool-only stream produces no anchor; provider failures still commit no assistant content ([decision](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md)).
 
 After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance follows the same adapter-default marker rule when resuming.
 

+ 1 - 1
packages/core/agent-loop/README.zh.md

@@ -63,7 +63,7 @@ interface Config {
 
 驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。包私有的编排入口点会恢复确切的 Agent,一次性派生 `agent.session`,并让操作局部的辅助函数捕获它,而不是通过浅层接口继续传递具体驱动器或每次操作的 `Session`。如果显式 `Session` 正是辅助函数的实际接口,该辅助函数会保留它;创建、持久化加载、未发布 setup、服务、worker、进程、持久化和 wire 协议则继续保留各自的显式身份。[agent 服务](../agent/README.md#initiating-agent-scope)规定传播、teardown 和分离工作规则。
 
-每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,在 `sourceEventSeqs` 中列出确切的分片 seq(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。
+每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,在 `sourceEventSeqs` 中列出确切的分片 seq(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。轮次取消打断流式输出时,如果非空文本或推理内容已送达用户,循环也会追加一个带 `interrupted: true` 的锚点。该锚点引用对应的分片 seq,并把已渲染的前缀放入派生消息历史,使下一次请求包含用户看到的内容。未分派的工具调用会被省略,空流或只包含工具调用的流不会生成锚点;提供方故障也不提交 assistant 内容([决策](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md))。
 
 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器负责的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会遵循同一套适配器默认值标记规则。
 

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

@@ -342,14 +342,33 @@ export class ReactLoopAgent implements Agent {
       )
       const assembler = new BlockAssembler()
       const chunkSeqs: number[] = []
-      const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
-      signal.throwIfAborted()
-      for await (const chunk of stream) {
+      try {
+        const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
+        signal.throwIfAborted()
+        for await (const chunk of stream) {
+          signal.throwIfAborted()
+          chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
+          assembler.push(chunk)
+        }
         signal.throwIfAborted()
-        chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
-        assembler.push(chunk)
+      } catch (error: unknown) {
+        if (signal.aborted) {
+          const content = assembler.interruptedBlocks()
+          if (content.length > 0) {
+            this.session.append('assistant/message', {
+              turn,
+              step,
+              message: createAssistantMessage({
+                content,
+                source: { provider: request.provider, model: request.model },
+              }),
+              interrupted: true,
+              ...assembler.usage === undefined ? {} : { usage: assembler.usage },
+            }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })
+          }
+        }
+        throw error
       }
-      signal.throwIfAborted()
       const finish = assembler.finish
       if (finish.kind === 'error' || finish.kind === 'aborted') {
         const action = await this.dispatch.waterfall(

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

@@ -1,4 +1,4 @@
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
 /**
  * Tests for the queue-aware `Agent.cancel()` primitive. The default clears
  * queued and steering work, while `keepInbox` preserves pending input for a
@@ -479,6 +479,161 @@ describe('Agent.cancel()', () => {
     expect(reasons.length).toBe(2)
   })
 
+  it('cancel mid-stream finalizes the streamed prefix onto the surface', async () => {
+    const adapter = new MockAdapter(['hang', textResponse('after')])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(SessionId('partial-finalize'), { provider: 'mock', model: 'mock' })
+
+    send(agent, 'go')
+    await new Promise(r => setTimeout(r, 30))
+    agent.cancel({ kind: 'user' })
+    await waitForIdle(ctx, agent)
+
+    // The prefix the user watched stream is committed as the step's message,
+    // 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)
+    expect(types.indexOf('assistant/message')).toBeLessThan(types.indexOf('step/end'))
+    expect(types.indexOf('step/end')).toBeLessThan(types.indexOf('turn/end'))
+
+    // The next request derives the finalized prefix: the model sees what the user saw.
+    send(agent, 'continue')
+    await waitForIdle(ctx, agent)
+    const replayed = adapter.requests[1]!.messages
+      .filter(m => m.role === 'assistant')
+      .flatMap(m => m.content)
+      .flatMap(b => b.type === 'text' ? [b.text] : [])
+    expect(replayed).toContain('partial')
+  })
+
+  it('cancel during reasoning-only streaming finalizes the reasoning prefix', async () => {
+    const adapter = new MockAdapter([{
+      hangAfter: [
+        { type: 'block-start', index: 0, blockType: 'reasoning' },
+        { type: 'reasoning-delta', index: 0, text: 'thinking about it' },
+        { type: 'usage', usage: { inputTokens: 7, outputTokens: 4 } },
+      ],
+    }])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(SessionId('reasoning-finalize'), { provider: 'mock', model: 'mock' })
+
+    send(agent, 'go')
+    await new Promise(r => setTimeout(r, 30))
+    agent.cancel({ kind: 'user' })
+    await waitForIdle(ctx, agent)
+
+    const message = agent.session.events.find(e => e.type === 'assistant/message')
+    expect(message?.type === 'assistant/message' ? message.data.message.content : undefined)
+      .toEqual([{ type: 'reasoning', text: 'thinking about it' }])
+    // A usage chunk delivered before the cancel travels with the finalized prefix.
+    expect(message?.type === 'assistant/message' ? message.data.usage : undefined)
+      .toEqual({ inputTokens: 7, outputTokens: 4 })
+  })
+
+  it('cancel drops a half-streamed tool call and keeps the completed text before it', async () => {
+    const adapter = new MockAdapter([{
+      hangAfter: [
+        { type: 'block-start', index: 0, blockType: 'text' },
+        { type: 'text-delta', index: 0, text: 'reading the file' },
+        { type: 'block-end', index: 0, block: { type: 'text', text: 'reading the file' } },
+        { type: 'block-start', index: 1, blockType: 'tool-call' },
+        { type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read', argumentsDelta: '{"pa' },
+      ],
+    }])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(SessionId('tool-call-drop'), { provider: 'mock', model: 'mock' })
+
+    send(agent, 'go')
+    await new Promise(r => setTimeout(r, 30))
+    agent.cancel({ kind: 'user' })
+    await waitForIdle(ctx, agent)
+
+    // The undispatched call is dropped whole — no dangling tool_use to pair.
+    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: 'reading the file' }])
+    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: [
+        { type: 'block-start', index: 0, blockType: 'tool-call' },
+        { type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'read', argumentsDelta: '{"pa' },
+      ],
+    }])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create(SessionId('nothing-to-finalize'), { provider: 'mock', model: 'mock' })
+
+    send(agent, 'go')
+    await new Promise(r => setTimeout(r, 30))
+    agent.cancel({ kind: 'user' })
+    await waitForIdle(ctx, agent)
+
+    expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false)
+  })
+
   it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
     const adapter = new MockAdapter([textResponse('should not stream')])
     const ctx = await harness(adapter)

+ 17 - 3
packages/core/agent-loop/tests/mock-adapter.ts

@@ -55,19 +55,25 @@ export function toolCallResponse(rawCallId: string, name: string, args: object,
   return chunks
 }
 
+/** Script entry that streams the given chunks, then hangs until aborted. */
+export interface HangAfter {
+  hangAfter: StreamChunk[]
+}
+
 /**
  * Mock adapter driven by a script: each model call consumes the next entry.
  * Records every request it receives for assertions. An entry may be a
  * function to compute chunks from the request, a 'hang' marker that
- * streams one chunk then waits until aborted, or 'hang-slow' which takes
+ * streams one chunk then waits until aborted, 'hang-slow' which takes
  * 50ms to notice the abort — a stand-in for slow real-world teardown
- * (LLM stream cancellation, tool unwinding).
+ * (LLM stream cancellation, tool unwinding) — or a {@link HangAfter}
+ * scripting the exact chunks delivered before the hang.
  */
 export class MockAdapter extends LlmAdapter {
   requests: GenerateOptions[] = []
 
   constructor(
-    private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow')[],
+    private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow' | HangAfter)[],
     private readonly reasoning?: LlmModelReasoningInfo,
     private readonly defaultMaxTokens?: number,
   ) {
@@ -100,6 +106,14 @@ export class MockAdapter extends LlmAdapter {
       })
       return
     }
+    if (typeof entry === 'object' && !Array.isArray(entry) && 'hangAfter' in entry) {
+      for (const chunk of entry.hangAfter) yield chunk
+      await new Promise<void>((_resolve, reject) => {
+        if (options.signal?.aborted) { reject(new Error('aborted')); return }
+        options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
+      })
+      return
+    }
     if (entry === 'hang-slow') {
       yield { type: 'block-start', index: 0, blockType: 'text' }
       yield { type: 'text-delta', index: 0, text: 'partial' }

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

@@ -268,9 +268,13 @@ export interface SessionEventMap {
    * Assembled assistant message for one step (derived history uses this).
    * Carries the step's `usage` when the adapter reported token accounting, so
    * the model output and its accounting travel together (there is no separate
-   * usage record). `usage` is absent when the adapter reported none.
+   * usage record). `usage` is absent when the adapter reported none. A turn
+   * cancelled mid-stream finalizes its delivered text/reasoning prefix as this
+   * event with `interrupted: true`; undispatched tool calls are absent. The
+   * marker distinguishes that prefix without re-deriving interruption 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
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -3866,7 +3866,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',

+ 21 - 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
@@ -157,6 +158,25 @@ export class BlockAssembler {
     return this.assembled().blocks
   }
 
+  /**
+   * Assemble the prefix an interrupted stream can safely finalize: closed and
+   * open text/reasoning blocks with non-whitespace content, in stream order.
+   * Tool calls are omitted because interruption precedes dispatch; retaining
+   * one would require a fabricated result. Open unknown blocks are also omitted.
+   * @returns the kept blocks; empty when nothing streamed before the interruption.
+   */
+  interruptedBlocks(): ContentBlock[] {
+    return this.order
+      .map((index) => {
+        const partial = this.mustGet(index)
+        const type = partial.block?.type ?? partial.blockType
+        if (type !== 'text' && type !== 'reasoning') return undefined
+        return this.assemble(partial, index)
+      })
+      .filter((block): block is ContentBlock =>
+        (block?.type === 'text' || block?.type === 'reasoning') && block.text.trim() !== '')
+  }
+
   /** Usage from the `usage` chunk; undefined until one arrives. */
   get usage(): TokenUsage | undefined {
     return this._usage

+ 38 - 0
packages/llm/llm/tests/assembler.spec.ts

@@ -224,3 +224,41 @@ describe('BlockAssembler duplicate-close contract', () => {
     expect(assembler.blocks()).toEqual([{ type: 'reasoning', text: 'first' }])
   })
 })
+
+describe('BlockAssembler.interruptedBlocks', () => {
+  it('keeps closed and open text/reasoning blocks with streamed content, in order', () => {
+    const assembler = new BlockAssembler()
+    assembler.push({ type: 'block-start', index: 0, blockType: 'reasoning' })
+    assembler.push({ type: 'reasoning-delta', index: 0, text: 'planning' })
+    assembler.push({ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'planning' } })
+    assembler.push({ type: 'block-start', index: 1, blockType: 'text' })
+    assembler.push({ type: 'text-delta', index: 1, text: 'half an ans' })
+    expect(assembler.interruptedBlocks()).toEqual([
+      { type: 'reasoning', text: 'planning' },
+      { type: 'text', text: 'half an ans' },
+    ])
+  })
+
+  it('drops tool calls whether open or closed — interruption precedes dispatch', () => {
+    const assembler = new BlockAssembler()
+    assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
+    assembler.push({ type: 'text-delta', index: 0, text: 'calling' })
+    assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'calling' } })
+    assembler.push({ type: 'block-start', index: 1, blockType: 'tool-call' })
+    assembler.push({ type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read', argumentsDelta: '{"a":1}' })
+    assembler.push({ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"a":1}' } })
+    assembler.push({ type: 'block-start', index: 2, blockType: 'tool-call' })
+    assembler.push({ type: 'tool-call-delta', index: 2, id: CallId('c2'), name: 'read', argumentsDelta: '{"pa' })
+    expect(assembler.interruptedBlocks()).toEqual([{ type: 'text', text: 'calling' }])
+  })
+
+  it('drops empty and whitespace-only text/reasoning blocks and unknown open block types', () => {
+    const assembler = new BlockAssembler()
+    assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
+    assembler.push({ type: 'text-delta', index: 0, text: '  \n' })
+    // A merge-extended block kind this build does not know how to assemble.
+    assembler.push({ type: 'block-start', index: 1, blockType: 'mystery' } as unknown as StreamChunk)
+    assembler.push({ type: 'block-start', index: 2, blockType: 'reasoning' })
+    expect(assembler.interruptedBlocks()).toEqual([])
+  })
+})

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio