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
@@ -26,6 +26,9 @@
* array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
* member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare
* string (wire-validation probes).
* - `FAKE_EMPTY_MESSAGE`: the turn's assistant/message has EMPTY content (a
* usage-only max-tokens step) after streaming the text chunk — a consumer
* must keep the streamed text instead of the empty message.
* - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
* arrives, then poll for the GO file before answering (deterministic
@@ -117,7 +120,10 @@ function runTurn(sessionId: string): void {
message: {
id: `fake-assistant-${seq}`,
role: 'assistant',
content: [{ type: 'text', text }],
// FAKE_EMPTY_MESSAGE: a usage-only terminal message (the harness loop
// appends one when a max-tokens step assembled no text blocks) whose
// empty content must not erase the text streamed above.
content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
})