fix(subagent): complete output selection contract

This commit is contained in:
Hypatia May
2026-08-10 22:19:14 +08:00
parent 084638af47
commit 4f23fa84cc
32 changed files with 113 additions and 93 deletions
@@ -15,6 +15,22 @@ function reasoningDelta(text: string): SessionEvent {
return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent
}
function toolResult(text: string): SessionEvent {
return {
type: 'tool/result',
data: {
message: {
content: [{
type: 'tool-result',
toolCallId: 'call-1',
content: [{ type: 'text', text }],
isError: false,
}],
},
},
} as SessionEvent
}
describe('finalAssistantOutput', () => {
it('selects the last non-empty message past a later empty usage-only message', () => {
const events = [
@@ -25,19 +41,30 @@ describe('finalAssistantOutput', () => {
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }])
})
it('prefers a non-empty message over the streamed text', () => {
it('prefers a non-empty message over text streamed before and after it', () => {
const events = [
textDelta('streamed '),
textDelta('text'),
textDelta('earlier partial'),
message([{ type: 'text', text: 'complete answer' }]),
textDelta('later partial'),
message([]),
]
expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }])
})
it('falls back to accumulated text deltas when no non-empty message exists', () => {
it('treats textless assistant content as a non-empty message', () => {
const content: ContentBlock[] = [{ type: 'reasoning', text: 'complete reasoning' }]
expect(finalAssistantOutput([
textDelta('streamed text'),
message(content),
textDelta('later partial'),
])).toEqual(content)
})
it('falls back to text deltas without including reasoning or tool-result content', () => {
const events = [
reasoningDelta('thinking'),
textDelta('partial '),
toolResult('tool output'),
textDelta('answer'),
message([]),
]
@@ -1201,10 +1201,9 @@ describe('continuable review regressions', () => {
})
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.
// A tool-only max-tokens step records an empty assistant/message for
// usage. The terminal event retains the previous assistant content,
// including its tool call but not the intervening tool result.
const { ctx, parent } = await setup([
toolCallResponse('t1', 'noop', {}, 'partial one'),
[
@@ -264,8 +264,8 @@ describe('SubagentService', () => {
stopReason: 'completed',
}))
// "No output" has ONE encoding on the end edge: the field is absent,
// never an empty array, matching the continuable epoch edge.
// The lifecycle event omits lastAssistantMessage when output is empty,
// matching the continuable epoch event.
const silent = new StubProvider('silent', NO_CAPS, { output: [], stopReason: 'completed' })
subagents.registerProvider(silent)
const silentRun = await subagents.start('silent', baseRequest())