Cancelled streams finalize their delivered prefix
被取消的流定稿其已送达前缀
A cancelled stream can leave transient chunks that clients have rendered 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.
English
Problem
A cancelled stream can leave transient chunks that clients have rendered 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, compact stream accumulator, and provider route identify the delivered prefix. It appends that prefix as the step's assistant/message with interrupted: true, surfaceOp: 'append', and the exact embedded timed stream. The append precedes the committed agent/assistant-stream end frame, 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 assistant/attempt instead of a surface message. Provider error and aborted finishes also commit assistant/attempt before agent/request-error, so their streams remain durable without contributing failed-request content to model history.
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 provider information. 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 the embedded attempt during projection. deriveMessages() and Client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative surface 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 retain their stream in assistant/attempt but keep its content out of model history. Only the user's cancellation decision turns visible delivered text into an interrupted surface message.
Testing
packages/core/agent-loop/tests/cancel.spec.ts covers content, embedded streams, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix attempt. packages/llm/llm/tests/assembler.spec.ts covers interruptedBlocks(). packages/client/ui-chat/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.
中文
Problem
被取消的流可能留下 Client 已经渲染的瞬态 chunk,但如果没有 assistant/message 记录已送达前缀,deriveMessages() 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。
模型历史必须包含取消后仍对用户可见的 assistant 内容。
Decision
ReactLoopAgent.step() 在消费模型 stream 期间捕捉取消,此时 BlockAssembler、紧凑 stream accumulator 与 provider route 可以确定已送达前缀。loop 把该前缀追加为 step 的 assistant/message,并设置 interrupted: true、surfaceOp: 'append' 与精确嵌入式带时间 stream。该追加先于 committed agent/assistant-stream end frame、step/end 和记录 aborted 的 turn/end。
BlockAssembler.interruptedBlocks() 按 stream 顺序返回内容非空白的已闭合和未闭合 text 与 reasoning block。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空 block 和未闭合的未知 block 类型。返回结果为空时追加 assistant/attempt,而不是 surface message。Provider error 与 aborted finish 也会在 agent/request-error 前提交 assistant/attempt,因此其 stream 保持持久,但失败请求内容不会进入模型历史。
Chat 和 Trajectory Conversation Definition 从持久 message 读取 interrupted。Chat 渲染 Stopped marker,Trajectory 则在 step/end 后把 provider request 保持在 error 生命周期,并保留持久 result seq 与 provider 信息。工具执行期间的取消遵循工具调度器约定,因为 assistant message 已提交:已启动的调用生成真实结果,未分派的调用获得 ABORTED_BEFORE_DISPATCH 结果。
Alternatives considered
始终丢弃前缀。 这能避免新增持久标记,但每次取消后的追问和分支都会缺少仍对用户可见的 assistant 内容。
在投影时从嵌入式 attempt 组装前缀。 deriveMessages() 与 Client Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 surface message。这还会让模型历史超出三类 SurfaceEventType 事件。
保留完整工具调用并合成 aborted 结果。 这些调用从未分派,合成结果会声称一个并未发生的执行结果,还会增加用户未收到的工具结果内容。
追加 [interrupted by user] 之类模型可见的打断消息。 这可以告诉模型前缀并不完整,但需要独立的来源类型、投影规则、UI 处理和本地化文案。持久的 aborted turn/end 保留了该后续决策所需的事实。
Consequences
取消后的追问和分支会包含已送达前缀。ACP 桥会在结算 prompt 前排空按序传送的 assistant 输出,因此最后一条 agent_message_chunk 更新先于 cancelled stop reason。
终局 provider error 会在 assistant/attempt 中保留其 stream,但不让内容进入模型历史。只有用户的取消决策会把可见的已送达文本变成 interrupted surface message。
Testing
packages/core/agent-loop/tests/cancel.spec.ts 覆盖 content、嵌入式 stream、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀 attempt。packages/llm/llm/tests/assembler.spec.ts 覆盖 interruptedBlocks()。packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts 与 packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts 覆盖两种 Client 投影。keyless cancel ACP snapshot 与 goal-round-driver goal snapshot 覆盖组装应用。