fix(subagent): keep output past an empty terminal message with one selection rule

A max-tokens step that assembled only tool-call blocks appends an
EMPTY-content assistant/message (the usage host). Three consumers each
hand-rolled output selection and all let it erase the child's real
answer: the in-process readResult and the Activation subagent/end
capture took the last message unfiltered, and the SDK backend let any
message beat its streamed-text fallback; the in-process driver also had
no streamed-text fallback for cancelled turns.

dsh-subagent now owns the canonical rule in src/assistant-output.ts
(last non-empty assistant message, else the accumulated text-delta
stream) and all three consumers apply it. Regression tests in all three
packages fail under the previous selections.

Closes #1514
This commit is contained in:
Hypatia May
2026-08-10 11:16:29 +08:00
parent abaf8f5061
commit 1db1cda464
29 changed files with 344 additions and 66 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/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md
2026-08-10-subagent-empty-terminal-message-output.md: ece2a55930aabfaeaf88ef1757918296b32bfea5
2026-08-10-subagent-empty-terminal-message-output.zh.md: 00b8f5bd7bd8e71935f68af2c35521f2b2377186
@@ -0,0 +1,27 @@
# Agent Note: One selection rule keeps subagent output past an empty terminal message
Status: implemented
English | [中文](2026-08-10-subagent-empty-terminal-message-output.zh.md)
## Problem
The agent loop appends an EMPTY-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks (`BlockAssembler.blocks()` drops truncated tool calls): the message exists solely to host usage. Three consumers each selected "the child's answer" with their own rule and all treated that usage host as the answer. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture took the LAST `assistant/message` unfiltered, and the SDK backend's observer let any `assistant/message` beat its streamed-text fallback. In a multi-step turn cut off at max-tokens, the final empty message therefore erased the real partial answer: `SubagentResult.output` came back `[]`, and the tool result, telemetry, and `subagent/end.lastAssistantMessage` all saw nothing. The in-process driver additionally had no streamed-text fallback at all, so a cancelled child whose only text lived in `assistant/chunk` events also reported `[]`.
## Decision
`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: the last NON-EMPTY assistant message wins; without one, the accumulated `text-delta` stream is the answer; empty-content messages never contribute. `finalAssistantOutput(events)` applies the rule to an event suffix (the in-process `readResult` and the Activation capture), and `assistantMessageOutput(event)` is the same per-event predicate for the SDK backend's incremental fold. The contract is stated once at `SubagentResult.output` and mirrored by the subsystem reference; `subagent/end.lastAssistantMessage` declares it selects by the same rule. A `max-tokens` or `aborted` finish still reports its honest stop reason; only output selection changed.
The ACP backend accumulates chunks only and was never affected. The fake SDK runtime gained a `FAKE_EMPTY_MESSAGE` mode so the keyless backend test can script a usage-only terminal message.
## Alternatives considered
**Fix each consumer in place without a shared helper.** Rejected: the defect existed precisely because three hand-rolled selections drifted; observers of one run must agree on its answer, so the rule needs one implementation (the drafts that first proved the defect, PR #1140 and PR #1141, patched two of the three call sites separately and left the Activation capture inconsistent).
**Stop the loop from appending the empty message.** Rejected: the message is the usage host and the step's durable record ("model-visible ⟺ logged"); reshaping session events for a consumer-side selection bug would touch every replay and projection consumer.
**Treat empty-content messages as an error.** Rejected: the streamed text is the child's real partial answer, and the stop reason already tells the consumer the turn was cut short.
## Consequences
Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children keep the text streamed before the abort; one-shot and continuable `subagent/end` edges agree with `SubagentResult.output`. A message whose content is non-empty but textless (for example reasoning-only) still wins over streamed text — the rule is about empty content, not text presence. Regression tests in all three packages script the empty-terminal-message and cancel paths and fail under the previous selections.
@@ -0,0 +1,27 @@
# Agent Note: 用同一条选取规则在空终止消息后保留子代理输出
Status: implemented
[English](2026-08-10-subagent-empty-terminal-message-output.md) | 中文
## 问题
`max-tokens` 步骤只组装了工具调用块时(`BlockAssembler.blocks()` 会丢弃被截断的工具调用),agent loop 会追加一条内容为**空**的 `assistant/message`——这条消息仅用于承载 usage。三个消费方各自用自己的规则选取"子代理的回答",并且都把这个 usage 宿主当成了回答:进程内驱动的 `readResult` 和 continuable Activation 的 `subagent/end` capture 不加过滤地取**最后一条** `assistant/message`SDK 后端的观察器则让任何 `assistant/message` 覆盖其流式文本兜底。于是在被 max-tokens 截断的多步回合中,最后那条空消息抹掉了真实的部分回答:`SubagentResult.output` 返回 `[]`,工具结果、遥测和 `subagent/end.lastAssistantMessage` 全都看不到任何内容。此外进程内驱动完全没有流式文本兜底,因此被取消的子代理若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`
## 决策
`dsh-subagent``src/assistant-output.ts` 中拥有唯一的规范选取规则:最后一条**非空** assistant 消息优先;没有时,累积的 `text-delta` 流就是回答;空内容消息从不参与。`finalAssistantOutput(events)` 把该规则应用于事件后缀(进程内 `readResult` 与 Activation capture),`assistantMessageOutput(event)` 是同一规则的逐事件谓词,供 SDK 后端的增量折叠使用。契约在 `SubagentResult.output` 处声明一次,并由子系统参考文档镜像;`subagent/end.lastAssistantMessage` 声明按同一规则选取。`max-tokens``aborted` 终止仍然如实上报其终止原因;只有输出选取发生了变化。
ACP 后端只累积分块,从未受影响。fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息。
## 考虑过的替代方案
**各消费方就地修复、不抽共享辅助函数。** 之所以否决:缺陷恰恰源于三处手写选取的漂移;同一次运行的观察方必须对其回答达成一致,因此规则需要唯一实现(最早证明该缺陷的草稿 PR #1140 与 PR #1141 分别修补了三处调用点中的两处,留下 Activation capture 不一致)。
**让 loop 不再追加空消息。** 之所以否决:这条消息是 usage 宿主,也是该步骤的持久化记录("model-visible ⟺ logged");为一个消费方侧的选取缺陷重塑会话事件,会波及所有 replay 与 projection 消费方。
**把空内容消息视为错误。** 之所以否决:流式文本才是子代理真实的部分回答,且终止原因已经告诉消费方轮次被截断。
## 后果
被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。
+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/event-producer-consumer.md
event-producer-consumer.md: 11eecf81a4eccadf2b97026154a78e4ed8a72164
event-producer-consumer.zh.md: 2db5e596465b4adaf98c1b05692b61de3ced47b9
event-producer-consumer.md: 1849bf5bd4df5e58370b1eaf5846f14a604fa4f6
event-producer-consumer.zh.md: f280c50076fa2799e52556aa31a6d2316c151e17
+4 -4
View File
@@ -37,10 +37,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:137`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:154`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
+4 -4
View File
@@ -39,10 +39,10 @@
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:137`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:154`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
+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/subagent.md
subagent.md: 99c54e0696c9c3585c50285ecb69b14c90d83c11
subagent.zh.md: c5a79247a81f5174662f2b5d1ed2d3c20cf6c672
subagent.md: 4d72ca7a3fd42438e46a50558287e0e85450963d
subagent.zh.md: e8b1c948fa0ac3fe47097f0dd8f2532bdb4a8d20
+11 -6
View File
@@ -293,7 +293,12 @@ The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
*/
interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
/**
* The child's final assistant output: the content of the last NON-EMPTY
* assistant message (an empty-content message hosts only usage and is
* skipped), else the text streamed before the turn was cut short, or `[]`
* when the child produced none.
*/
readonly output: ContentBlock[]
/**
* The structured result after a requested `outputSchema` was successfully
@@ -613,7 +618,7 @@ async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md)
Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:168`](../../packages/subagent/subagent/src/index.ts)
<a id="subagent-events"></a>
@@ -639,7 +644,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare
Types: [Scoped](scope.md)
Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:163`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentprovider-added--emit"></a>
@@ -656,7 +661,7 @@ A provider became resolvable in the registry.
'subagent/provider-added'(provider: SubagentProvider): void
```
Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:137`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentprovider-removed--emit"></a>
@@ -673,7 +678,7 @@ A provider left the registry. Accepted runs remain holder-owned.
'subagent/provider-removed'(name: string): void
```
Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:143`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentstart--emit"></a>
@@ -697,5 +702,5 @@ A provider established a published child. For in-process providers, `ctx.agents.
Types: [Scoped](scope.md)
Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:154`](../../packages/subagent/subagent/src/index.ts)
<!-- END GENERATED cordis-surface -->
+11 -6
View File
@@ -293,7 +293,12 @@ type SubagentDescendantListEntry = SubagentListEntry & {
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
*/
interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
/**
* The child's final assistant output: the content of the last NON-EMPTY
* assistant message (an empty-content message hosts only usage and is
* skipped), else the text streamed before the turn was cut short, or `[]`
* when the child produced none.
*/
readonly output: ContentBlock[]
/**
* The structured result after a requested `outputSchema` was successfully
@@ -615,7 +620,7 @@ async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md)
Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:168`](../../packages/subagent/subagent/src/index.ts)
<a id="subagent-events"></a>
@@ -641,7 +646,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare
Types: [Scoped](scope.md)
Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:163`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentprovider-added--emit"></a>
@@ -658,7 +663,7 @@ A provider became resolvable in the registry.
'subagent/provider-added'(provider: SubagentProvider): void
```
Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:137`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentprovider-removed--emit"></a>
@@ -675,7 +680,7 @@ A provider left the registry. Accepted runs remain holder-owned.
'subagent/provider-removed'(name: string): void
```
Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:143`](../../packages/subagent/subagent/src/index.ts)
<a id="subagentstart--emit"></a>
@@ -699,5 +704,5 @@ A provider established a published child. For in-process providers, `ctx.agents.
Types: [Scoped](scope.md)
Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:154`](../../packages/subagent/subagent/src/index.ts)
<!-- END GENERATED cordis-surface -->
@@ -26,6 +26,9 @@
* array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
* member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare
* string (wire-validation probes).
* - `FAKE_EMPTY_MESSAGE`: the turn's assistant/message has EMPTY content (a
* usage-only max-tokens step) after streaming the text chunk — a consumer
* must keep the streamed text instead of the empty message.
* - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
* arrives, then poll for the GO file before answering (deterministic
@@ -117,7 +120,10 @@ function runTurn(sessionId: string): void {
message: {
id: `fake-assistant-${seq}`,
role: 'assistant',
content: [{ type: 'text', text }],
// FAKE_EMPTY_MESSAGE: a usage-only terminal message (the harness loop
// appends one when a max-tokens step assembled no text blocks) whose
// empty content must not erase the text streamed above.
content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
})
@@ -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/subagent/subagent-dsh-sdk/README.md
README.md: 0bbcfa105ecf024a2492d39d3bf8d28956110050
README.zh.md: 8c8551f85951aa8475ab2ce95771e4d54e0ed89a
README.md: 80f5c40a2c949b7c2a638ec19c02950e8cd69f0b
README.zh.md: b34421dbbf2d06b7c9236776aabf19ba8005204e
+1 -1
View File
@@ -10,7 +10,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a
The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session.
The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths.
The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete NON-EMPTY `assistant/message` (an empty-content message hosts only usage and is skipped), or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths.
`dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit.
@@ -10,7 +10,7 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS
工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。
返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整的 `assistant/message`,或该活动中断前已经累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。
返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且**非空**`assistant/message`(空内容消息仅承载 usage,会被跳过),或该活动中断前已经累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。
`dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。
+10 -6
View File
@@ -16,7 +16,7 @@ import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk-
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
import { assistantMessageOutput, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
/** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */
@@ -163,17 +163,21 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
}
const childSessionId = `session-${randomUUID().replaceAll('-', '')}`
// The child's final answer: the last complete assistant message when one
// exists, else the text streamed so far (a partial answer surviving cancel).
// The child's final answer, folded incrementally under the seam's canonical
// rule (`finalAssistantOutput`): the last NON-EMPTY complete assistant
// message when one exists, else the text streamed so far (a partial answer
// surviving cancel). An empty-content message hosts only usage (a max-tokens
// step that assembled no text blocks), so it never erases streamed text.
let lastMessage: ContentBlock[] | undefined
const partial: string[] = []
const observe = (notification: HarnessNotification): void => {
if (notification.method !== 'session.event' || notification.params.sessionId !== childSessionId) return
const event = notification.params.event as SessionEvent
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
const content = assistantMessageOutput(event)
if (content !== undefined) {
lastMessage = content
} else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
partial.push(event.data.chunk.text)
} else if (event.type === 'assistant/message') {
lastMessage = event.data.message.content
}
}
const collectOutput = (): ContentBlock[] => {
@@ -176,6 +176,20 @@ describe('dsh-subagent-dsh-sdk provider', () => {
await ctx.fiber.dispose()
})
it('keeps streamed text when the terminal message is an EMPTY usage-only step', async () => {
// The child streams its answer, then emits an empty-content
// assistant/message (the harness loop appends one to host usage on a
// max-tokens step that assembled no text blocks). The empty message is
// not assistant output and must not erase the streamed answer.
const ctx = await setup({ FAKE_EMPTY_MESSAGE: '1', FAKE_REASON_KIND: 'max-tokens' })
const run = await ctx.subagents.start('dsh-sdk', request())
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
expect(text(result.output)).toBe('hello from fake runtime')
await run.dispose()
await ctx.fiber.dispose()
})
it('reports a settled-without-turn child as an error', async () => {
const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' })
const run = await ctx.subagents.start('dsh-sdk', request())
@@ -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/subagent/subagent-inprocess/README.md
README.md: 67f0cf5dd1ecb18542af56953a0eaa40988aca0d
README.zh.md: 648a160be5f1c3dcbe66a867a273a3df610dbc0a
README.md: 2e2a3873843467b0811ccbc0ed1d9bb6a83eb31f
README.zh.md: 91013164e762bb01e7ad5a51597c6fa559c8d7a3
@@ -14,7 +14,7 @@ The driver follows this sequence:
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. Read the child's own last assistant message and final durable turn reason from the complete owned child run, excluding any fork seed.
5. Read the child's own output — its last NON-EMPTY assistant message (an empty-content message hosts only usage and is skipped), else the text it streamed before cancel or truncation cut the turn short — and the final durable turn reason from the complete owned child run, excluding any fork seed.
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
@@ -14,7 +14,7 @@
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。
4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
5. 从完整的自有子运行中读取子 agent 自身最后一条 assistant 消息和最终持久化的轮次原因,并排除任何 fork 初始内容。
5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条**非空** assistant 消息(空内容消息仅承载 usage,会被跳过),否则取轮次被取消或截断前已流式的文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
@@ -20,6 +20,7 @@ import {
applyChildComposition,
assertSubagentMaxDepth,
childSessionMeta,
finalAssistantOutput,
resolveChildAgentOptions,
resolveChildDepth,
} from '@deepseek-ai/dsh-subagent'
@@ -218,9 +219,11 @@ function readResult(
structured?: { captured?: { value: unknown } | undefined },
): SubagentResult {
const own = child.session.events.slice(boundary)
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
const lastEnd = findLastMessageTurnEnd(own)
const output: ContentBlock[] = lastMessage?.data.message.content ?? []
// Canonical selection (`finalAssistantOutput`): the last non-empty assistant
// message, else the text streamed before cancel/error/truncation cut the
// turn short — an empty usage-only message never erases real output.
const output: ContentBlock[] = finalAssistantOutput(own) ?? []
const recorded = toStopReason(lastEnd?.data.reason)
// Disposal can tear the owner down before the loop records its ordinary
// `aborted` end, yielding `disposed` instead.
@@ -1,4 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
@@ -10,7 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -155,6 +156,33 @@ describe('startInProcessRun', () => {
await run.dispose()
})
it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => {
// Step 1 streams "partial one" plus a tool call; step 2 hits max-tokens
// having assembled only a tool-call block, so the loop appends an EMPTY
// assistant/message to host usage. The empty message is not assistant
// output and must not erase step 1's text from the run's output.
const { ctx, parent } = await setup([
toolCallResponse('t1', 'noop', {}, 'partial one'),
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
],
])
const disposeNoop = ctx.tools.register(defineContentToolFixture({
name: 'noop', description: 'probe', parameters: {},
execute() { return Promise.resolve([{ type: 'text', text: 'noop result' }]) },
}))
const run = await startInProcessRun(request(parent), {})
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
expect(text(result.output)).toBe('partial one')
await run.dispose()
disposeNoop()
})
it('seeds a forked child but reads only the child-owned output', async () => {
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } }))
@@ -278,7 +306,12 @@ describe('startInProcessRun', () => {
const signalled = await startInProcessRun(request(parent, controller.signal), {})
await new Promise(resolve => setTimeout(resolve, 30))
controller.abort('stop child')
await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' })
// No step completed a message, so the text streamed before the abort is
// the cancelled run's output.
await expect(signalled.result).resolves.toEqual({
output: [{ type: 'text', text: 'partial' }],
stopReason: 'aborted',
})
expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' })
const child = parent.ctx.agents.get(signalled.id)
const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end')
+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/subagent/subagent/README.md
README.md: 762030629c09305c48adebc71244655a5faa6585
README.zh.md: 535cc25895e04e82b6667e6d2769f2dcbfa49cff
README.md: d2d5356fd82a47ecf5cd6b633e5338dde7047901
README.zh.md: 2fdc3ae6e8376ef7c7aaf11c8e85dd909ef92d2c
+1 -1
View File
@@ -56,7 +56,7 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure.
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` edge's `lastAssistantMessage` share one selection rule, implemented by the exported `finalAssistantOutput` helper: the child's last non-empty assistant message, else the text it streamed before the turn was cut short ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the contract).
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration.
+1 -1
View File
@@ -56,7 +56,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output``subagent/end` 边沿的 `lastAssistantMessage` 共用同一条选取规则,由导出的 `finalAssistantOutput` 辅助函数实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。
@@ -0,0 +1,51 @@
/**
* Canonical selection of a child's final assistant output from its session
* events. Every surface that reports "the child's answer" — backend run
* results and `subagent/end.lastAssistantMessage` — applies this one rule so
* observers agree: the last NON-EMPTY assistant message wins; an empty-content
* message hosts only usage (the loop appends one when a max-tokens step
* assembled no executable blocks) and never erases real output; without any
* non-empty message, the text streamed so far is the answer (a partial
* surviving cancel, error, and truncation paths).
*
* @module @deepseek-ai/dsh-subagent/assistant-output
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/**
* The content one event contributes as a candidate final answer: an
* `assistant/message` with non-empty content. An empty-content message hosts
* only usage and contributes none.
* @param event - any session event.
* @returns the message content, or `undefined` when this event is not a
* non-empty assistant message.
*/
export function assistantMessageOutput(event: SessionEvent): ContentBlock[] | undefined {
if (event.type !== 'assistant/message') return undefined
const content = event.data.message.content
return content.length > 0 ? content : undefined
}
/**
* Select the final assistant output from one child-owned event suffix: the
* last non-empty assistant message, else the accumulated `text-delta` stream.
* @param events - the child-owned events (after any seed or epoch boundary).
* @returns the selected output, or `undefined` when the child produced none.
*/
export function finalAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined {
let message: ContentBlock[] | undefined
const partial: string[] = []
for (const event of events) {
const content = assistantMessageOutput(event)
if (content !== undefined) {
message = content
} else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
partial.push(event.data.chunk.text)
}
}
if (message !== undefined) return message
const text = partial.join('')
return text.length > 0 ? [{ type: 'text', text }] : undefined
}
+1
View File
@@ -69,6 +69,7 @@ import { snapshotSubagentDescriptor } from './descriptor.ts'
import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
export * from './out-of-process.ts'
export { assistantMessageOutput, finalAssistantOutput } from './assistant-output.ts'
export { SubagentRunId } from './types.ts'
export type {
ContinuableCreateRequest,
+2 -14
View File
@@ -20,6 +20,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { finalAssistantOutput } from './assistant-output.ts'
import { SubagentRunId } from './types.ts'
import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
@@ -173,7 +174,7 @@ export function createActivationObserver(
},
capture: (child: Agent): void => {
const own = child.session.events.slice(boundary)
const output = lastAssistantOutput(own)
const output = finalAssistantOutput(own)
captured = {
stopReason: epochStopReason(own),
...output === undefined ? {} : { output },
@@ -220,19 +221,6 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR
}
}
/**
* The child's last assistant message content, for one Activation's terminal
* lifecycle edge. Absent when no assistant message reached the log.
* @param events - this epoch's own event suffix.
* @returns its final assistant content, or `undefined` when it produced none.
*/
function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined {
const message = events.findLast(
(event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message',
)
return message?.data.message.content
}
/** Render any listener-thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {
+11 -2
View File
@@ -64,7 +64,11 @@ export interface SubagentRunEndInfo {
readonly local: boolean
/** The terminal stop reason. */
readonly stopReason: SubagentResult['stopReason']
/** The child's final assistant output, absent on infrastructure rejection. */
/**
* The child's final assistant output, selected by the same rule as
* {@link SubagentResult.output}; absent on infrastructure rejection or when
* the child produced none.
*/
readonly lastAssistantMessage?: ContentBlock[]
}
@@ -213,7 +217,12 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM
* The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}.
*/
export interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
/**
* The child's final assistant output: the content of the last NON-EMPTY
* assistant message (an empty-content message hosts only usage and is
* skipped), else the text streamed before the turn was cut short, or `[]`
* when the child produced none.
*/
readonly output: ContentBlock[]
/**
* The structured result after a requested `outputSchema` was successfully
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { assistantMessageOutput, finalAssistantOutput } from '../src/assistant-output.ts'
function message(content: ContentBlock[]): SessionEvent {
return { type: 'assistant/message', data: { message: { content } } } as SessionEvent
}
function textDelta(text: string): SessionEvent {
return { type: 'assistant/chunk', data: { chunk: { type: 'text-delta', text } } } as SessionEvent
}
function reasoningDelta(text: string): SessionEvent {
return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent
}
describe('assistantMessageOutput', () => {
it('returns content only for a non-empty assistant message', () => {
const content: ContentBlock[] = [{ type: 'text', text: 'answer' }]
expect(assistantMessageOutput(message(content))).toBe(content)
expect(assistantMessageOutput(message([]))).toBeUndefined()
expect(assistantMessageOutput(textDelta('chunk'))).toBeUndefined()
})
})
describe('finalAssistantOutput', () => {
it('selects the last non-empty message past a later empty usage-only message', () => {
const events = [
message([{ type: 'text', text: 'step one' }]),
message([{ type: 'text', text: 'step two' }]),
message([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }])
})
it('prefers a non-empty message over the streamed text', () => {
const events = [
textDelta('streamed '),
textDelta('text'),
message([{ type: 'text', text: 'complete answer' }]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }])
})
it('falls back to accumulated text deltas when no non-empty message exists', () => {
const events = [
reasoningDelta('thinking'),
textDelta('partial '),
textDelta('answer'),
message([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'partial answer' }])
})
it('returns undefined when the child produced neither messages nor text', () => {
expect(finalAssistantOutput([])).toBeUndefined()
expect(finalAssistantOutput([reasoningDelta('thinking'), message([])])).toBeUndefined()
})
})
@@ -12,10 +12,10 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm'
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentService, {
SubagentError,
SUBAGENT_DESCRIPTOR_VERSION,
@@ -1200,6 +1200,45 @@ describe('continuable review regressions', () => {
expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }])
})
it('keeps the epoch\'s earlier text past a final empty usage-only message', async () => {
// Step 1 streams text plus a tool call; step 2 hits max-tokens having
// assembled only a tool-call block, so the loop appends an EMPTY
// assistant/message to host usage. The terminal edge reports the epoch's
// real answer text, not the internal usage marker.
const { ctx, parent } = await setup([
toolCallResponse('t1', 'noop', {}, 'partial one'),
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
],
])
ctx.tools.register(defineTool({
name: 'noop',
description: 'does nothing',
parameters: {},
output: {
schema: { type: 'object', additionalProperties: false, properties: {} },
render: () => [{ type: 'text', text: 'noop' }],
},
execute: () => Promise.resolve({}),
}))
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
expect(ends[0]!.stopReason).toBe('max-tokens')
expect(ends[0]!.lastAssistantMessage).toEqual([
{ type: 'text', text: 'partial one' },
{ type: 'tool-call', id: 't1', name: 'noop', arguments: '{}' },
])
})
it('reports a resumed epoch that opened no turn without the previous answer', async () => {
const { ctx, parent } = await setup([textResponse('first answer')])
const started = await ctx.subagents.startContinuable(startSpec(parent))