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
@@ -176,6 +176,20 @@ describe('dsh-subagent-dsh-sdk provider', () => {
await ctx.fiber.dispose()
})
it('keeps streamed text when the terminal message is an EMPTY usage-only step', async () => {
// The child streams its answer, then emits an empty-content
// assistant/message (the harness loop appends one to host usage on a
// max-tokens step that assembled no text blocks). The empty message is
// not assistant output and must not erase the streamed answer.
const ctx = await setup({ FAKE_EMPTY_MESSAGE: '1', FAKE_REASON_KIND: 'max-tokens' })
const run = await ctx.subagents.start('dsh-sdk', request())
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
expect(text(result.output)).toBe('hello from fake runtime')
await run.dispose()
await ctx.fiber.dispose()
})
it('reports a settled-without-turn child as an error', async () => {
const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' })
const run = await ctx.subagents.start('dsh-sdk', request())