Jelajahi Sumber

fix(snapshot): project headless reasoning stderr

lsdsjy 1 bulan lalu
induk
melakukan
7c7e4aada8

+ 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: b3fc80859a645431a3a172244d1a7b5a36deefc7
-2026-08-21-headless-reasoning-progress.zh.md: fde2ebac27512a75055ba75a35efc26918fb6eeb
+2026-08-21-headless-reasoning-progress.md: 6c4a3574b63ef316fd456f24b473406054ed30f3
+2026-08-21-headless-reasoning-progress.zh.md: e19fffc33fb5a55432ba2b6cb50550a0cfbd00b8

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

@@ -20,7 +20,7 @@ Reasoning progress is not TTY-gated and has no separate flag. A redirected stder
 
 ## Verification
 
-The package test holds the Agent active after a reasoning delta and observes stderr before idle, then pins newline ownership for provider-terminated and unterminated phases plus terminal errors. The keyless product snapshot drives the shipped headless profile through a reasoning-plus-tool round and pins both stderr and the persisted Session. Built-bin acceptance sends `reasoning_content` through the native DeepSeek SSE adapter and requires reasoning on stderr while stdout remains the final answer.
+The package test holds the Agent active after a reasoning delta and observes stderr before idle, then pins newline ownership for provider-terminated and unterminated phases plus terminal errors. The owner-local product expectation drives the shipped headless profile through a reasoning-plus-tool round and pins both stderr and the persisted Session. Recorded-session replay reconstructs expected stderr from scalar and packed chunk rows, closes sections on packed text and tool-call output, and uses the raw run log before fixture path tokenization in record modes. Built-bin acceptance sends `reasoning_content` through the native DeepSeek SSE adapter and requires reasoning on stderr while stdout remains the final answer.
 
 ## Alternatives considered
 

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

@@ -20,7 +20,7 @@ Status: implemented
 
 ## 验证
 
-包测试在推理分片后保持 Agent 活跃,并在 idle 前观察 stderr;测试同时固定由提供方终止和未终止的推理段换行归属,以及终止态错误。无密钥产品快照通过包含推理与工具调用的轮次驱动随附 headless profile,并固定 stderr 与持久化 Session。构建后二进制验收通过原生 DeepSeek SSE(Server-Sent Events)适配器发送 `reasoning_content`,要求推理出现在 stderr,同时 stdout 仍只包含最终答案。
+包测试在推理分片后保持 Agent 活跃,并在 idle 前观察 stderr;测试同时固定由提供方终止和未终止的推理段换行归属,以及终止态错误。产品自有期望通过包含推理与工具调用的轮次驱动随附 headless profile,并固定 stderr 与持久化 Session。录制会话回放从标量及压缩分片记录重建预期 stderr,在压缩文本或工具调用输出处关闭推理段,并在录制模式下于 fixture 路径标记化之前使用原始运行日志。构建后二进制验收通过原生 DeepSeek SSE(Server-Sent Events)适配器发送 `reasoning_content`,要求推理出现在 stderr,同时 stdout 仍只包含最终答案。
 
 ## 考虑过的替代方案
 

+ 92 - 3
snapshots/session/headless.snapshot.ts

@@ -258,13 +258,76 @@ function turnReasonFromSession(log: string): JsonObject | undefined {
 }
 
 function stderrFromSession(log: string): string {
+  let output = ''
+  let started = false
+  let open = false
+  let endsWithNewline = true
+  const appendReasoning = (text: string): void => {
+    if (text === '') return
+    if (!open) {
+      output += 'dsh: reasoning:\n'
+      open = true
+    }
+    output += text
+    endsWithNewline = text.endsWith('\n')
+  }
+  const close = (): void => {
+    if (!open) return
+    if (!endsWithNewline) output += '\n'
+    open = false
+    endsWithNewline = true
+  }
+  for (const record of records(log)) {
+    if (record.type === 'turn/start') {
+      close()
+      started = true
+      continue
+    }
+    if (!started) continue
+    const data = record.data as JsonObject | undefined
+    if (record.type === 'reasoning-chunks') {
+      if (!Array.isArray(data?.texts) || data.texts.some(text => typeof text !== 'string')) {
+        throw new Error('headless snapshot reasoning chunks have invalid text')
+      }
+      for (const text of data.texts as string[]) appendReasoning(text)
+      continue
+    }
+    if (record.type === 'text-chunks' || record.type === 'tool-call-chunks') {
+      close()
+      continue
+    }
+    if (record.type !== 'assistant/chunk') continue
+    const chunk = data?.chunk as JsonObject | undefined
+    switch (chunk?.type) {
+      case 'reasoning-delta':
+        if (typeof chunk.text !== 'string') throw new Error('headless snapshot reasoning delta has invalid text')
+        appendReasoning(chunk.text)
+        break
+      case 'block-start':
+        if (chunk.blockType !== 'reasoning') close()
+        break
+      case 'block-end': {
+        const block = chunk.block as JsonObject | undefined
+        if (block?.type !== 'reasoning') close()
+        break
+      }
+      case 'usage':
+        break
+      case 'text-delta':
+      case 'tool-call-delta':
+      case 'finish':
+        close()
+        break
+    }
+  }
+  close()
   const reason = turnReasonFromSession(log)
-  if (reason?.kind !== 'error') return ''
+  if (reason?.kind !== 'error') return output
   const error = reason.error as JsonObject | undefined
   if (typeof error?.code !== 'string' || typeof error.message !== 'string') {
     throw new Error('headless snapshot error reason has no code and message')
   }
-  return `dsh: ${error.code}: ${error.message}\n`
+  return `${output}dsh: ${error.code}: ${error.message}\n`
 }
 
 function modelFromSession(log: string): { provider: string; model: string } {
@@ -488,6 +551,28 @@ describe('headless recorded-session snapshots', () => {
     expect(logical(packed)).toStrictEqual(logical(source))
   })
 
+  it('reconstructs reasoning stderr across packed output boundaries', () => {
+    const log = [
+      { type: 'turn/start', data: { turn: 1 } },
+      { type: 'reasoning-chunks', data: { texts: ['first', ''] } },
+      { type: 'text-chunks', data: { texts: ['text'] } },
+      { type: 'reasoning-chunks', data: { texts: ['second'] } },
+      { type: 'tool-call-chunks', data: { args: ['{}'] } },
+      { type: 'reasoning-chunks', data: { texts: ['third\n'] } },
+      { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
+    ].map(record => JSON.stringify(record)).join('\n')
+
+    expect(stderrFromSession(log)).toBe([
+      'dsh: reasoning:',
+      'first',
+      'dsh: reasoning:',
+      'second',
+      'dsh: reasoning:',
+      'third',
+      '',
+    ].join('\n'))
+  })
+
   for (const scenario of scenarios) {
     const skipped = scenario.manifest.platform === 'posix' && process.platform === 'win32'
       || scenario.manifest.platform === 'pwsh' && !hasPwsh
@@ -587,12 +672,16 @@ describe('headless recorded-session snapshots', () => {
         await rm(spillRoot, { recursive: true, force: true })
       }
 
+      const stderrLog = mode === 'replay' ? primaryFixture : actualLogs[0]?.content
+      if (stderrLog === undefined) throw new Error(`${scenario.name}: stderr projection has no primary session`)
+      const expectedStderr = stderrFromSession(stderrLog)
+
       if (mode !== 'replay') {
         fixtures = await writeSessionFixtures(scenario, actualLogs, fixtures, contextOf(actualLogs.map(log => log.content)))
       }
 
       expect(result.stdout).toBe(`${finalTextFromSession(fixtures[0] as string)}\n`)
-      expect(result.stderr).toBe(stderrFromSession(fixtures[0] as string))
+      expect(result.stderr).toBe(expectedStderr)
       expect(actualLogs, `${scenario.name}: persisted session count`).toHaveLength(fixtures.length)
       const actualContext = contextOf(actualLogs.map(log => log.content))
       const fixtureContext = contextOf(fixtures)