From 7c7e4aada882a7ee337b1a303061577fa6696f2a Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:29:04 +0800 Subject: [PATCH] fix(snapshot): project headless reasoning stderr --- ...8-21-headless-reasoning-progress.i18n.yaml | 4 +- .../2026-08-21-headless-reasoning-progress.md | 2 +- ...26-08-21-headless-reasoning-progress.zh.md | 2 +- snapshots/session/headless.snapshot.ts | 95 ++++++++++++++++++- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.i18n.yaml index e78a592c98..7e4bf365f9 100644 --- a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.md b/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.md index b3fc80859a..6c4a3574b6 100644 --- a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.md +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.zh.md b/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.zh.md index fde2ebac27..e19fffc33f 100644 --- a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.zh.md +++ b/.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 仍只包含最终答案。 ## 考虑过的替代方案 diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index c22ede5c5b..2473ccd97d 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/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)