fix(snapshot): project headless reasoning stderr

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