fix(anthropic): assistant-aggregate fallback when claude emits no content_block_delta

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 22:38:19 +10:00
co-authored by Claude Opus 4.7
parent 97e7d16585
commit 65f945c16d
2 changed files with 74 additions and 8 deletions
+30 -5
View File
@@ -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;
}