From 65f945c16d84d848a5d3e19d40b587e09d68a19b Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Wed, 27 May 2026 22:38:19 +1000 Subject: [PATCH] fix(anthropic): assistant-aggregate fallback when claude emits no content_block_delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live PI231 v2.1.104 verification of 97e7d16 surfaced a real-runtime bug: in stream-json mode WITHOUT -p (the Transport A path locked by ADR 0009 Amendment 1), claude emits ONLY the aggregate {type:"assistant"} event for fast/short responses — NO content_block_delta events. The previous implementation returned null for `assistant` unconditionally, so the delta-chunk stream was empty and OpenAI-format responses came back with content=null. Empirical evidence (PI231 2026-05-27): - Manual `claude --output-format stream-json --verbose --no-session-persistence --system-prompt "..."` < "reply: DIAG-PROOF" → NDJSON stream contains system/init + assistant(content:[{text:"DIAG-PROOF"}]) + rate_limit_event + result. NO content_block_delta events. - OLP server response: choices[0].message.content == null - Provider direct test: chunks = [{type:"stop"}], no delta yielded Fix: - anthropicStreamJsonEventToIR(event, isFirstDelta) now handles assistant: - isFirstDelta=true → extract aggregate text from message.content, yield as single delta chunk (this is the "no streaming" path) - isFirstDelta=false → return null (duplicate of already-streamed deltas) - Non-text content blocks (thinking) are filtered out - Multi text blocks concatenate in order 4 new tests in Suite 41 covering: aggregate-when-streamed-null, aggregate-as- fallback-delta, multi-block-concat, thinking-block-filter. Tests: 790 → 793 (all pass). This was a brief-implementation gap: ADR 0009 Amendment 1 § "NDJSON event handling" mentioned "If we somehow get assistant without prior content_block_delta events (no streaming), then yield content as single delta" as a robustness fallback, but the original implementation treated the case as unreachable. Live verification proved it's the COMMON case when --include-partial-messages is not set (which we deliberately omit per the ADR's locked flag set). Authority: - claude CLI v2.1.104 § --output-format stream-json (NDJSON shape) - claude CLI v2.1.104 § --include-partial-messages (controls whether content_block_delta events are emitted; we omit it per ADR 0009 Amendment 1's locked flag set, accepting aggregate-only output) - ADR 0009 Amendment 1 § "NDJSON event handling" — `assistant` row already documents the fallback intent; this commit aligns implementation with intent - OLP ALIGNMENT.md Rule 1 — provider plugin authority citation Co-Authored-By: Claude Opus 4.7 --- lib/providers/anthropic.mjs | 35 +++++++++++++++++++++++---- test-features.mjs | 47 ++++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 8 deletions(-) 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)', () => {