diff --git a/lib/providers/anthropic.mjs b/lib/providers/anthropic.mjs index a35e3f3..d46de06 100644 --- a/lib/providers/anthropic.mjs +++ b/lib/providers/anthropic.mjs @@ -723,12 +723,37 @@ export function anthropicStreamJsonEventToIR(event, isFirstDelta = false) { return null; } - // assistant — aggregated message; already captured token-by-token via stream_event. - // If streaming worked, this is a duplicate and we return null. - // If no content_block_delta was seen (edge case), the caller falls back to - // proc.on('close') which emits a synthetic stop — we do NOT yield here either - // because we can't distinguish "streaming complete" from "assistant arrived early". + // assistant — aggregated message. Two cases: + // (a) Streaming via content_block_delta WAS happening → this aggregate is a duplicate, return null + // (b) claude emitted ONLY the aggregate (no content_block_delta) → must extract content here, + // otherwise the response body is empty + // + // Empirically (PI231 v2.1.104, 2026-05-27 live transcripts): when running without `-p` + // and without --include-partial-messages, claude emits ONLY the aggregate `assistant` + // event for fast/short responses — no content_block_delta events. The reviewer brief's + // "fall back" branch (track whether ANY content_block_delta was seen; if not, use + // assistant.message.content text) is load-bearing for these responses. + // + // isFirstDelta=true means no delta has been emitted yet → fall back to extract here. + // isFirstDelta=false means deltas were already streamed → this is a duplicate, ignore. if (t === 'assistant') { + if (isFirstDelta) { + const blocks = event.message?.content; + if (Array.isArray(blocks)) { + const text = blocks + .filter(b => b && b.type === 'text' && typeof b.text === 'string') + .map(b => b.text) + .join(''); + if (text) { + return { + type: 'delta', + content: text, + role: 'assistant', + model: event.message?.model, + }; + } + } + } return null; } diff --git a/test-features.mjs b/test-features.mjs index 551eb1d..d805d2e 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -17587,10 +17587,51 @@ describe('Suite 41 — ADR 0009 Amendment 1: stream-json transport (Phase 6)', ( assert.equal(chunk, null, 'rate_limit_event must return null (future audit/dashboard work)'); }); - it('41e-7: anthropicStreamJsonEventToIR — assistant (aggregate) → null', () => { + it('41e-7: anthropicStreamJsonEventToIR — assistant (aggregate) when deltas already streamed → null', () => { const event = { type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } }; - const chunk = anthropicStreamJsonEventToIR(event, false); - assert.equal(chunk, null, 'assistant aggregate event must return null (deltas already streamed)'); + const chunk = anthropicStreamJsonEventToIR(event, /* isFirstDelta */ false); + assert.equal(chunk, null, 'assistant aggregate event must return null when deltas already streamed (duplicate)'); + }); + + it('41e-7b: anthropicStreamJsonEventToIR — assistant (aggregate) with no prior delta → fallback yields delta', () => { + // Fold-in: when claude runs without -p (and without --include-partial-messages), + // it does NOT emit content_block_delta events — only the aggregate `assistant`. + // Verified empirically on PI231 v2.1.104 2026-05-27 live transcripts. + // If isFirstDelta=true (no prior delta yielded), fall back to extracting the + // aggregate content as a single delta chunk so the response body is non-empty. + const event = { + type: 'assistant', + message: { model: 'claude-sonnet-4-6', content: [{ type: 'text', text: 'DIAG-PROOF' }] }, + }; + const chunk = anthropicStreamJsonEventToIR(event, /* isFirstDelta */ true); + assert.equal(chunk?.type, 'delta', 'assistant with no prior delta should yield delta chunk'); + assert.equal(chunk?.content, 'DIAG-PROOF', 'delta content must equal the aggregate text'); + assert.equal(chunk?.role, 'assistant', 'role on first-delta'); + assert.equal(chunk?.model, 'claude-sonnet-4-6', 'model on first-delta from message.model'); + }); + + it('41e-7c: anthropicStreamJsonEventToIR — assistant with multi-block content concatenates', () => { + const event = { + type: 'assistant', + message: { content: [ + { type: 'text', text: 'Part 1. ' }, + { type: 'text', text: 'Part 2.' }, + ]}, + }; + const chunk = anthropicStreamJsonEventToIR(event, /* isFirstDelta */ true); + assert.equal(chunk?.content, 'Part 1. Part 2.', 'multi text blocks concatenate in order'); + }); + + it('41e-7d: anthropicStreamJsonEventToIR — assistant with non-text blocks filtered (e.g. thinking)', () => { + const event = { + type: 'assistant', + message: { content: [ + { type: 'thinking', thinking: 'internal reasoning' }, + { type: 'text', text: 'visible answer' }, + ]}, + }; + const chunk = anthropicStreamJsonEventToIR(event, /* isFirstDelta */ true); + assert.equal(chunk?.content, 'visible answer', 'non-text blocks (thinking) filtered out'); }); it('41e-8: anthropicStreamJsonEventToIR — parse_error event → null (already logged)', () => {