mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(snapshot): project headless reasoning stderr
This commit is contained in:
@@ -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 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 仍只包含最终答案。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user