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:
@@ -1,4 +1,4 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
@@ -10,7 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
@@ -155,6 +156,33 @@ describe('startInProcessRun', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => {
|
||||
// Step 1 streams "partial one" 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 empty message is not assistant
|
||||
// output and must not erase step 1's text from the run's output.
|
||||
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' } },
|
||||
],
|
||||
])
|
||||
const disposeNoop = ctx.tools.register(defineContentToolFixture({
|
||||
name: 'noop', description: 'probe', parameters: {},
|
||||
execute() { return Promise.resolve([{ type: 'text', text: 'noop result' }]) },
|
||||
}))
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('max-tokens')
|
||||
expect(text(result.output)).toBe('partial one')
|
||||
await run.dispose()
|
||||
disposeNoop()
|
||||
})
|
||||
|
||||
it('seeds a forked child but reads only the child-owned output', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } }))
|
||||
@@ -278,7 +306,12 @@ describe('startInProcessRun', () => {
|
||||
const signalled = await startInProcessRun(request(parent, controller.signal), {})
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
controller.abort('stop child')
|
||||
await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' })
|
||||
// No step completed a message, so the text streamed before the abort is
|
||||
// the cancelled run's output.
|
||||
await expect(signalled.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: 'partial' }],
|
||||
stopReason: 'aborted',
|
||||
})
|
||||
expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' })
|
||||
const child = parent.ctx.agents.get(signalled.id)
|
||||
const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end')
|
||||
|
||||
Reference in New Issue
Block a user