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
+44 -3
View File
@@ -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)', () => {