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

feat(agent-loop): finalize a cancelled stream's delivered prefix
This commit is contained in:
CreatixChu
2026-08-19 15:39:14 +08:00
committed by GitHub
41 changed files with 510 additions and 75 deletions
@@ -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
@@ -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.
@@ -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 快照覆盖完整应用。
@@ -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}}
@@ -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}}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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}}
@@ -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"}}}}
@@ -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,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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 子调用树
@@ -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
}
@@ -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
@@ -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,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
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
## Model Experience
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
## 模型体验
@@ -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,
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
assembler.push(chunk)
for await (const chunk of stream) {
signal.throwIfAborted()
chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
assembler.push(chunk)
}
signal.throwIfAborted()
} 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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([])
})
})