fix(subagent): keep output past an empty terminal message with one selection rule

A max-tokens step that assembled only tool-call blocks appends an
EMPTY-content assistant/message (the usage host). Three consumers each
hand-rolled output selection and all let it erase the child's real
answer: the in-process readResult and the Activation subagent/end
capture took the last message unfiltered, and the SDK backend let any
message beat its streamed-text fallback; the in-process driver also had
no streamed-text fallback for cancelled turns.

dsh-subagent now owns the canonical rule in src/assistant-output.ts
(last non-empty assistant message, else the accumulated text-delta
stream) and all three consumers apply it. Regression tests in all three
packages fail under the previous selections.

Closes #1514
This commit is contained in:
Hypatia May
2026-08-10 11:15:01 +08:00
parent abaf8f5061
commit 1db1cda464
29 changed files with 344 additions and 66 deletions
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { assistantMessageOutput, finalAssistantOutput } from '../src/assistant-output.ts'
function message(content: ContentBlock[]): SessionEvent {
return { type: 'assistant/message', data: { message: { content } } } as SessionEvent
}
function textDelta(text: string): SessionEvent {
return { type: 'assistant/chunk', data: { chunk: { type: 'text-delta', text } } } as SessionEvent
}
function reasoningDelta(text: string): SessionEvent {
return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent
}
describe('assistantMessageOutput', () => {
it('returns content only for a non-empty assistant message', () => {
const content: ContentBlock[] = [{ type: 'text', text: 'answer' }]
expect(assistantMessageOutput(message(content))).toBe(content)
expect(assistantMessageOutput(message([]))).toBeUndefined()
expect(assistantMessageOutput(textDelta('chunk'))).toBeUndefined()
})
})
describe('finalAssistantOutput', () => {
it('selects the last non-empty message past a later empty usage-only message', () => {
const events = [
message([{ type: 'text', text: 'step one' }]),
message([{ type: 'text', text: 'step two' }]),
message([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }])
})
it('prefers a non-empty message over the streamed text', () => {
const events = [
textDelta('streamed '),
textDelta('text'),
message([{ type: 'text', text: 'complete answer' }]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }])
})
it('falls back to accumulated text deltas when no non-empty message exists', () => {
const events = [
reasoningDelta('thinking'),
textDelta('partial '),
textDelta('answer'),
message([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'partial answer' }])
})
it('returns undefined when the child produced neither messages nor text', () => {
expect(finalAssistantOutput([])).toBeUndefined()
expect(finalAssistantOutput([reasoningDelta('thinking'), message([])])).toBeUndefined()
})
})
@@ -12,10 +12,10 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm'
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentService, {
SubagentError,
SUBAGENT_DESCRIPTOR_VERSION,
@@ -1200,6 +1200,45 @@ describe('continuable review regressions', () => {
expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }])
})
it('keeps the epoch\'s earlier text past a final empty usage-only message', async () => {
// Step 1 streams text plus a tool call; step 2 hits max-tokens having
// assembled only a tool-call block, so the loop appends an EMPTY
// assistant/message to host usage. The terminal edge reports the epoch's
// real answer text, not the internal usage marker.
const { ctx, parent } = await setup([
toolCallResponse('t1', 'noop', {}, 'partial one'),
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
],
])
ctx.tools.register(defineTool({
name: 'noop',
description: 'does nothing',
parameters: {},
output: {
schema: { type: 'object', additionalProperties: false, properties: {} },
render: () => [{ type: 'text', text: 'noop' }],
},
execute: () => Promise.resolve({}),
}))
const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) })
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
expect(ends[0]!.stopReason).toBe('max-tokens')
expect(ends[0]!.lastAssistantMessage).toEqual([
{ type: 'text', text: 'partial one' },
{ type: 'tool-call', id: 't1', name: 'noop', arguments: '{}' },
])
})
it('reports a resumed epoch that opened no turn without the previous answer', async () => {
const { ctx, parent } = await setup([textResponse('first answer')])
const started = await ctx.subagents.startContinuable(startSpec(parent))