Kaynağa Gözat

fix(headless): preserve reasoning block continuity

lsdsjy 1 ay önce
ebeveyn
işleme
2813ef2a95

+ 2 - 2
.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.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-21-headless-reasoning-progress.md
-2026-08-21-headless-reasoning-progress.md: 714d9bc4d5671c2ba777f8605472142401b5d532
-2026-08-21-headless-reasoning-progress.zh.md: 1698bb43ff3fff5ef748a4697028224c209d43dd
+2026-08-21-headless-reasoning-progress.md: b3fc80859a645431a3a172244d1a7b5a36deefc7
+2026-08-21-headless-reasoning-progress.zh.md: fde2ebac27512a75055ba75a35efc26918fb6eeb

+ 1 - 1
.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.md

@@ -12,7 +12,7 @@ The earlier [direct core entry-point decision](../architecture/2026-08-09-headle
 
 ## Decision
 
-`headless-runner` observes the exact Session it creates after startup quiescence and before submitting the task. Once the owned interval opens with `turn/start`, each non-empty `assistant/chunk.reasoning-delta` is written immediately to stderr. A contiguous reasoning phase starts with `dsh: reasoning:` on its own line; deltas retain provider order without token-boundary decoration. The first later non-reasoning chunk, a new turn, or listener disposal terminates the phase with one newline when the provider supplied none.
+`headless-runner` observes the exact Session it creates after startup quiescence and before submitting the task. Once the owned interval opens with `turn/start`, each non-empty `assistant/chunk.reasoning-delta` is written immediately to stderr. A contiguous reasoning phase starts with `dsh: reasoning:` on its own line; deltas retain provider order without token-boundary decoration. Reasoning block boundaries and usage metadata keep that phase open; a later non-reasoning block or output delta, stream finish, new turn, or listener disposal terminates it with one newline when the provider supplied none.
 
 This output is a transient projection of the existing durable Session event stream. The runner still derives final text and exit status from the flushed log rather than from progress-presentation state. The LLM adapter, agent loop, Session event types, persistence format, and SDK projections do not change.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决策
 
-`headless-runner` 在启动工作完全停稳后、提交任务前,观察其创建的精确 Session。自身持有的区间以 `turn/start` 打开后,每个非空的 `assistant/chunk.reasoning-delta` 都会立即写入 stderr。一段连续推理以独占一行的 `dsh: reasoning:` 开始;各分片保持提供方顺序,不添加 token 边界装饰。之后出现首个非推理分片、新轮次或 listener dispose(资源释放)时,如果提供方没有输出末尾换行,runner 会用一个换行终止该段。
+`headless-runner` 在启动工作完全停稳后、提交任务前,观察其创建的精确 Session。自身持有的区间以 `turn/start` 打开后,每个非空的 `assistant/chunk.reasoning-delta` 都会立即写入 stderr。一段连续推理以独占一行的 `dsh: reasoning:` 开始;各分片保持提供方顺序,不添加 token 边界装饰。推理块边界与用量元数据会保持该段打开;之后出现非推理块或输出分片、流结束、新轮次或 listener dispose(资源释放)时,如果提供方没有输出末尾换行,runner 会用一个换行终止该段。
 
 该输出是既有持久化会话事件流的瞬时投影。runner 仍从 flush 后的日志而不是进度呈现状态推导最终文本与退出状态。LLM(大语言模型)适配器、agent loop(智能体循环)、Session 事件类型、持久化格式与 SDK 投影均不改变。
 

+ 2 - 2
packages/bundle/headless/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/bundle/headless/README.md
-README.md: 0bd2fac0d3ab59332d42788a2b1f15599b6bcab2
-README.zh.md: 610a908420ce5b214881242eb4b4a48c1262953a
+README.md: 373e50c515ef45d09c32e7dd1b011f149d921250
+README.zh.md: 443945ced7e5e41899e91e8c169918f4e186de67

+ 1 - 0
packages/bundle/headless/README.md

@@ -20,4 +20,5 @@ None; the runner adds nothing to the request prefix.
 
 - **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval.
 - **No pre-token heartbeat** — stderr remains silent until the provider emits a non-empty reasoning delta; a provider that delays its first streamed token exposes no earlier progress signal.
+- **Reasoning enters stderr logs** — redirection and supervisors may retain substantially more and potentially sensitive model output; route stderr to a controlled sink when that content must not be collected.
 - **`ctx.appExit` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the exit request.

+ 1 - 0
packages/bundle/headless/README.zh.md

@@ -20,4 +20,5 @@ Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/a
 
 - **只提交一个任务**:runner 没有用于交互式后续输入的 surface;它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。
 - **首个 token 前没有心跳**:在提供方发出非空推理分片前,stderr 保持静默;如果提供方延迟首个流式 token,系统不会提供更早的进度信号。
+- **推理会进入 stderr 日志**:重定向与监督进程可能保留明显更多且可能敏感的模型输出;不得收集该内容时,应将 stderr 送往受控目标。
 - **`ctx.appExit` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该退出请求。

+ 9 - 1
packages/bundle/headless/src/index.ts

@@ -123,7 +123,15 @@ function streamReasoning(
       endsWithNewline = chunk.text.endsWith('\n')
       return
     }
-    if (chunk.type === 'block-start' && chunk.blockType === 'reasoning') return
+    if (chunk.type === 'block-start') {
+      if (chunk.blockType !== 'reasoning') close()
+      return
+    }
+    if (chunk.type === 'block-end') {
+      if (chunk.block.type !== 'reasoning') close()
+      return
+    }
+    if (chunk.type === 'usage') return
     close()
   })
   return () => {

+ 4 - 4
packages/bundle/headless/src/invariant.ts

@@ -14,10 +14,10 @@ export const name = 'headless-invariant'
 export const inject = ['invariants']
 
 /**
- * No runtime invariant: the runner is a one-shot driver over the API carrier
- * whose observable contract (final text on stdout, exit code by turn-end
- * reason) is process-level and owned by the launcher e2e; it registers
- * nothing and holds no mutable relation to audit inside the tree.
+ * No runtime invariant: the runner's observable contract (provider reasoning
+ * on stderr, final text on stdout, exit code by turn-end reason) is
+ * process-level and owned by the launcher e2e; it registers nothing and holds
+ * no mutable relation to audit inside the tree.
  */
 const install: InvariantInstaller = () => {}
 

+ 33 - 3
packages/bundle/headless/tests/headless.spec.ts

@@ -173,12 +173,42 @@ describe('headless runner', () => {
           step: 1,
           chunk: { type: 'reasoning-delta', index: 0, text: ' safely\n' },
         })
+        session.append('assistant/chunk', {
+          turn: 1,
+          step: 1,
+          chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'checking the workspace safely\n' } },
+        })
+        session.append('assistant/chunk', {
+          turn: 1,
+          step: 1,
+          chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 2 } },
+        })
+        session.append('assistant/chunk', {
+          turn: 1,
+          step: 1,
+          chunk: { type: 'block-start', index: 1, blockType: 'reasoning' },
+        })
+        session.append('assistant/chunk', {
+          turn: 1,
+          step: 1,
+          chunk: { type: 'reasoning-delta', index: 1, text: 'second pass\n' },
+        })
         reasoningAppended.resolve(undefined)
         await release.promise
         session.append('assistant/chunk', {
           turn: 1,
           step: 1,
-          chunk: { type: 'block-start', index: 1, blockType: 'text' },
+          chunk: { type: 'block-start', index: 2, blockType: 'text' },
+        })
+        session.append('assistant/chunk', {
+          turn: 1,
+          step: 1,
+          chunk: { type: 'text-delta', index: 2, text: 'done' },
+        })
+        session.append('assistant/chunk', {
+          turn: 1,
+          step: 1,
+          chunk: { type: 'block-end', index: 2, block: { type: 'text', text: 'done' } },
         })
         session.append('assistant/message', {
           turn: 1,
@@ -207,13 +237,13 @@ describe('headless runner', () => {
     const result = await running
     expect(streamed).toEqual({
       out: '',
-      err: 'dsh: reasoning:\nchecking the workspace safely\n',
+      err: 'dsh: reasoning:\nchecking the workspace safely\nsecond pass\n',
       order: [],
     })
     expect(result).toEqual({
       code: 0,
       out: 'done\n',
-      err: 'dsh: reasoning:\nchecking the workspace safely\n',
+      err: 'dsh: reasoning:\nchecking the workspace safely\nsecond pass\n',
       order: ['flush', 'exit'],
     })
     await test.ctx.fiber.dispose()