浏览代码

test(subagent): reuse Messages fixtures and clarify notice contracts

Tianyi Cui 4 天之前
父节点
当前提交
1d26330415

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
-2026-08-06-manager-owned-subagent-settlement-delivery.md: 9d24ddd011a0fe0a38f7e313b71c4591e2083fee
-2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: bfc39e20c6280b7aaa8fdb36c64ea8f162be3f4c
+2026-08-06-manager-owned-subagent-settlement-delivery.md: 5553d4c06a6776f0ef025e19ae7f4f67fe277a56
+2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 6d7ee756ad531e123508e8667adc4e5f99c2b9ea

+ 3 - 3
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md

@@ -16,11 +16,11 @@ The signal already existed. `subagent/end` has carried `stopReason` and `lastAss
 
 The continuation manager delivers the account itself, from inside the disposal transaction that ends the Activation.
 
-When a resident Activation settles, `notifySettlement()` resolves the child's durable direct parent and sends it one user-role message: the epoch's outcome as a sentence the parent can act on, then the text from the child's final assistant output, or a statement that it produced no closing text. Delivery is unconditional for every child whose id a caller actually received. It does not consult whether the child reported, and it keeps no bookkeeping that could make the promise conditional — that unconditionality is what lets `tool-subagent` promise a runtime notice containing the outcome and any final assistant text. A materialization rolled back before its first accepted message stays silent, because the caller was told that child was not established.
+When a resident Activation settles, `notifySettlement()` resolves the child's durable direct parent and sends it one user-role message: the epoch's outcome as a sentence the parent can act on, then the text from the child's final assistant output, or a statement that it produced no closing text. Delivery is unconditional for every child whose id a caller actually received. It does not consult whether the child reported, and it keeps no bookkeeping that could make the promise conditional — that unconditionality is what lets `tool-subagent` promise a runtime notice containing `its outcome and any final assistant message`. A materialization rolled back before its first accepted message stays silent, because the caller was told that child was not established.
 
 ### Closing text
 
-[`createSettlementMessage()`](../../../../packages/subagent/subagent/src/continuation-messages.ts) projects the selected assistant output to nonempty text blocks before creating the user-role notice. It preserves text bytes and block order, excludes every nontext block, and uses `It left no closing message.` when no nonempty text remains. Reasoning and tool calls are assistant content that DeepSeek Messages cannot represent in a user message. The conversion belongs to notice construction; `AssistantOutputFold`, `SubagentResult.output`, and `subagent/end.lastAssistantMessage` retain complete child output for SDK and UI consumers.
+[`createSettlementMessage()`](../../../../packages/subagent/subagent/src/continuation-messages.ts) projects the selected assistant output to nonempty text blocks before creating the user-role notice. It preserves text bytes and block order, excludes every nontext block, and uses `It left no closing message.` when no nonempty text remains. Text-only notices work across parent providers without depending on their support for particular content types. Reasoning and tool calls, for example, cannot appear in a DeepSeek Messages user message; images are accepted by Messages but not by every parent model. The conversion belongs to notice construction; `AssistantOutputFold`, `SubagentResult.output`, and `subagent/end.lastAssistantMessage` retain complete child output for SDK and UI consumers.
 
 ### Runtime source
 
@@ -66,7 +66,7 @@ Both matter past the notice: `subagent/end` carries `stopReason` to the jsonrpc
 
 Three assembled ACP scenarios cover the notice: a child that sends no message, a child that sends a message first, and a child driven through several Agent-message turns. All three need an explicit fence. The notice arrives once the child's teardown finishes, which races whatever the parent is already doing, so each scenario holds the child behind the parent's spawn turn and then waits for the parent turn the notice opens (`waitForTurnStart` at that turn, then `waitForTurnEnd`) before the script continues. Waiting for a turn the run is not fenced to produce is not coverage: it is a timeout when the notice lands in the turn already running instead.
 
-`subagent-continuable` is the one that pins a failure. Its child's last turn dies on the forced durability checkpoint without entering a step, so that transcript is where the stop-reason rule above is visible end to end: the notice says the child *failed*, carries the earlier `SECOND_OK` as its last content rather than as a result, and the parent's own acknowledgement turn reaches the ACP client.
+`subagent-continuable` pins a completed SDK settlement with `SECOND_OK` and no child reasoning in the parent's notice; its child finishes in turn 1, so the configured turn-3 checkpoint failure is not exercised.
 
 A keyless headless Loader snapshot covers the user-visible path end to end. Its replay parent omits `run_in_background` to exercise the continuable background default, never calls `list_agents`, `send_message`, or Task tools, consumes the manager-authored `subagent-settled` notice, and produces its final answer. The child sends no Agent message, so the transcript depends only on the runtime notice. A test-only Loader fence holds the parent's post-spawn request until the real manager notice enters its inbox, removing platform scheduling from the transcript without synthesizing the notice.
 

+ 3 - 3
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md

@@ -16,11 +16,11 @@ Status: implemented
 
 继续执行管理器自己投递这份记账,就在结束 Activation 的那笔 dispose 事务内部完成。
 
-当驻留 Activation 结算时,`notifySettlement()` 解析该 child 持久化的直接父级,并向它发送一条用户角色消息:先是父级可据以行动的一句结果说明,然后是 child 最终 assistant 输出中的文本,或一句说明它没有产出收尾文本。对每个调用方真正拿到过 id 的 child,投递都是无条件的。它不查询 child 是否上报过,也不保留任何可能让这项承诺变成有条件的记账——正是这种无条件性,才让 `tool-subagent` 能够承诺一条包含结局与可能存在的最终 assistant 文本的运行时通知。在第一条消息被接受之前就回滚的物化保持静默,因为调用方已被告知该 child 未建立。
+当驻留 Activation 结算时,`notifySettlement()` 解析该 child 持久化的直接父级,并向它发送一条用户角色消息:先是父级可据以行动的一句结果说明,然后是 child 最终 assistant 输出中的文本,或一句说明它没有产出收尾文本。对每个调用方真正拿到过 id 的 child,投递都是无条件的。它不查询 child 是否上报过,也不保留任何可能让这项承诺变成有条件的记账——正是这种无条件性,才让 `tool-subagent` 能够承诺一条包含 `its outcome and any final assistant message` 的运行时通知。在第一条消息被接受之前就回滚的物化保持静默,因为调用方已被告知该 child 未建立。
 
 ### 收尾文本
 
-[`createSettlementMessage()`](../../../../packages/subagent/subagent/src/continuation-messages.ts) 在创建用户角色通知前,将选中的 assistant 输出投影为非空文本块。它保留文本字节与块顺序,排除所有非文本块,并在没有剩余非空文本时使用 `It left no closing message.`。推理与工具调用属于 assistant 内容,DeepSeek Messages 无法在用户消息中表示它们。这项转换归通知构建所有;`AssistantOutputFold`、`SubagentResult.output` 与 `subagent/end.lastAssistantMessage` 为 SDK 和 UI 消费方保留完整的子级输出。
+[`createSettlementMessage()`](../../../../packages/subagent/subagent/src/continuation-messages.ts) 在创建用户角色通知前,将选中的 assistant 输出投影为非空文本块。它保留文本字节与块顺序,排除所有非文本块,并在没有剩余非空文本时使用 `It left no closing message.`。纯文本通知适用于不同的父级提供方,不依赖它们对特定内容类型的支持。例如,推理与工具调用不能出现在 DeepSeek Messages 的用户消息中;Messages 接受图片,但并非每个父级模型都支持图片。这项转换归通知构建所有;`AssistantOutputFold`、`SubagentResult.output` 与 `subagent/end.lastAssistantMessage` 为 SDK 和 UI 消费方保留完整的子级输出。
 
 ### 来源信息
 
@@ -66,7 +66,7 @@ Status: implemented
 
 三个整体组装的 ACP 场景覆盖该通知:一个不发送消息的 child、一个先发送消息的 child,以及一个被多轮 Agent 消息驱动的 child。三者都需要显式栅栏。通知在 child 拆卸完成后才到达,会与父级当时正在做的事竞争,因此每个场景都会把 child 保持到父级启动轮次结束,再等待该通知开启的那个父级轮次(先 `waitForTurnStart` 到该轮次,再 `waitForTurnEnd`),然后脚本才继续。等待一个运行并未被栅栏保证会产生的轮次不算覆盖:一旦通知落进已经在跑的那个轮次,它就是一次超时。
 
-`subagent-continuable` 是其中固定失败结局的那个。它的 child 最后一个轮次在被强制的持久化检查点上死亡,且未进入任何 step,因此该 transcript 正是上面那条终止原因规则的端到端可见之处:通知说该 child **失败**,把此前的 `SECOND_OK` 作为它最后产出的内容而非结果携带,而父级自己的确认轮次会到达 ACP 客户端
+`subagent-continuable` 固定了 SDK 中已完成的结算:父级通知包含 `SECOND_OK`,但不含子级推理;子级在第 1 轮结束,因此未覆盖已配置的第 3 轮检查点失败
 
 另有一个无密钥的 headless Loader 快照端到端覆盖用户可见路径。其重放父级省略 `run_in_background` 以覆盖可继续后台默认路径,从不调用 `list_agents`、`send_message` 或 Task 工具,消费管理器写入的 `subagent-settled` 通知,并给出最终答案。child 不发送 Agent 消息,因此该 transcript 只依赖运行时通知。一个仅用于测试的 Loader 栅栏会把父级启动后的请求保持到真实管理器通知进入其 inbox 为止,从 transcript 中排除平台调度差异,但不会伪造该通知。
 

+ 2 - 2
packages/subagent/subagent/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
-README.md: 16cc8f1802e51916c77d92e6807beef50fe92df3
-README.zh.md: 57d7cb846dc35eb35b087153cffee99375dad972
+README.md: 74cca45462f17e1de74e2fa0132c4e7dccad414b
+README.zh.md: 39e598c3e88d2f82811c85357c3b82d98eec7327

+ 1 - 0
packages/subagent/subagent/README.md

@@ -174,6 +174,7 @@ These limits define when the seam is a poor fit or needs special operational car
 - **Wake gap during cancellation convergence** — a follow-up accepted after an interrupt signal but before the driver becomes idle stays queued until another waking send.
 - **Pending injected context retains an Activation** — settlement conservatively treats every Inbox occurrence as unfinished. Context parked after the Agent becomes idle keeps the child and its live ancestors resident until a waking delivery claims it, a queue mutation removes it, or manager teardown discards it.
 - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store needs a durable mailbox and cross-process lease protocol.
+- **Saved settlement notices are not rewritten** — a saved user-role notice containing reasoning still fails DeepSeek Messages serialization while it remains in the parent's request history.
 - **No replay of accepted-but-unlogged messages** — a crash can lose an accepted prompt that never reached the child's session log; the lost message is not replayed automatically.
 - **No durable parent mailbox** — child-to-parent messages require a resident continuable child and live direct parent, and provide acceptance identity rather than exactly-once delivery.
 - **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision API waits for a concrete consumer.

+ 1 - 0
packages/subagent/subagent/README.zh.md

@@ -174,6 +174,7 @@ You are a delegated subagent: your permission scope was fixed when you were star
 - **取消收敛期间存在唤醒缺口**——中断信号发出后、driver 进入 idle 前被接受的后续消息会保持排队,直到另一条唤醒发送到达。
 - **待处理的注入上下文会保留 Activation**——settlement 会保守地把每个 Inbox occurrence 都视为未完成。Agent 进入 idle 后停放的上下文会让 child 及其在线祖先继续驻留,直到唤醒投递将其 claim、queue 变更将其移除,或 manager teardown 将其丢弃。
 - **驻留仅限进程内**——Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问需要持久化邮箱与跨进程租约协议。
+- **已保存的结算通知不会被改写**——若用户角色的已保存通知含有推理块,只要它仍在父级请求历史中,DeepSeek Messages 序列化就会失败。
 - **不回放已接受但未记录的消息**——崩溃可能丢失从未写入子会话日志、已被接受的提示词;丢失的消息不会自动回放。
 - **没有持久化 parent mailbox**——child 到 parent 的消息要求驻留的可继续 child 与在线直接 parent,提供的是接受标识,不保证恰好一次投递。
 - **生命周期事件只供观察**——影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。

+ 3 - 0
packages/subagent/subagent/src/continuation-messages.ts

@@ -137,6 +137,9 @@ export function createSettlementMessage(
   terminal: ActivationTerminal,
 ): ReturnType<typeof createUserMessage> {
   const summary = settlementSummary(childId, terminal.stopReason)
+  // Parent providers receive this notice as a user message and may reject
+  // nontext assistant blocks. Keep this conversion local so SDK/UI consumers
+  // retain the complete child output.
   const closingText = (terminal.output ?? []).flatMap(block =>
     block.type === 'text' && block.text.length > 0 ? [block] : [],
   )

+ 13 - 36
packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts

@@ -1,4 +1,3 @@
-import { createServer } from 'node:http'
 import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
@@ -12,46 +11,30 @@ import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-dee
 import { SessionId } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
+import { end, MODEL, server, sse, start } from '../../../llm/llm-deepseek/tests/messages/helpers.ts'
 import SubagentRuntime, { type SubagentRunEndInfo } from '../src/index.ts'
 import { loadStoredSession } from './persistence-helpers.ts'
 
 it('continues the parent through default Messages after a reasoning-bearing continuable child settles', async () => {
   const root = mkdtempSync(join(tmpdir(), 'dsh-settlement-messages-'))
   const ctx = new Context()
-  const requests: { path: string | undefined; body: unknown }[] = []
-  const model = 'deepseek-v4-flash'
-  const http = createServer((request, response) => {
-    void (async () => {
-      const parts: Buffer[] = []
-      for await (const part of request as AsyncIterable<Buffer>) parts.push(part)
-      const body: unknown = JSON.parse(Buffer.concat(parts).toString())
-      requests.push({ path: request.url, body })
-      const blocks = requests.length === 1
+  let http: Awaited<ReturnType<typeof server>> | undefined
+  try {
+    http = await server((response, count) => {
+      const blocks = count === 1
         ? [{ type: 'thinking', thinking: 'child reasoning' }, { type: 'text', text: 'child answer' }]
         : [{ type: 'text', text: 'parent answer' }]
-      const events = [
-        { type: 'message_start', message: { id: `reply-${requests.length}`, model, usage: { input_tokens: 10, output_tokens: 1 } } },
+      response.end(sse([
+        start,
         ...blocks.flatMap((content_block, index) => [
           { type: 'content_block_start', index, content_block },
           { type: 'content_block_stop', index },
         ]),
-        { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 5 } },
-        { type: 'message_stop' },
-      ]
-      response.writeHead(200, { 'content-type': 'text/event-stream' })
-      response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''))
-    })().catch((error: unknown) => { response.destroy(error as Error) })
-  })
-  let listening = false
-  try {
-    await new Promise<void>((resolve, reject) => {
-      http.once('error', reject)
-      http.listen(0, '127.0.0.1', resolve)
+        ...end(),
+      ]))
     })
-    listening = true
-    const address = http.address()
-    if (address === null || typeof address === 'string') throw new Error('missing Messages fixture address')
-    const connection = resolveAdapterOptions({ baseURL: `http://127.0.0.1:${address.port}/anthropic` })
+    const { requests } = http
+    const connection = resolveAdapterOptions({ baseURL: http.url })
     const adapter = new DeepSeekAdapter({
       options: () => connection,
       resolveApiKey: () => Promise.resolve('test-key'),
@@ -64,7 +47,7 @@ it('continues the parent through default Messages after a reasoning-bearing cont
     await ctx.plugin(SubagentRuntime)
     await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
     ctx.llm.registerAdapter(['deepseek-official'], adapter)
-    const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'deepseek-official', model })
+    const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'deepseek-official', model: MODEL })
     const ends: SubagentRunEndInfo[] = []
     const settled = Promise.withResolvers<undefined>()
     ctx.on('subagent/end', (info) => {
@@ -114,13 +97,7 @@ it('continues the parent through default Messages after a reasoning-bearing cont
       await ctx.fiber.dispose()
     } finally {
       try {
-        if (listening) await new Promise<void>((resolve, reject) => {
-          http.close((error) => {
-            if (error === undefined) resolve()
-            else reject(error)
-          })
-          http.closeAllConnections()
-        })
+        await http?.close()
       } finally {
         rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
       }