mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
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:
@@ -723,12 +723,37 @@ export function anthropicStreamJsonEventToIR(event, isFirstDelta = false) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// assistant — aggregated message; already captured token-by-token via stream_event.
|
// assistant — aggregated message. Two cases:
|
||||||
// If streaming worked, this is a duplicate and we return null.
|
// (a) Streaming via content_block_delta WAS happening → this aggregate is a duplicate, return null
|
||||||
// If no content_block_delta was seen (edge case), the caller falls back to
|
// (b) claude emitted ONLY the aggregate (no content_block_delta) → must extract content here,
|
||||||
// proc.on('close') which emits a synthetic stop — we do NOT yield here either
|
// otherwise the response body is empty
|
||||||
// because we can't distinguish "streaming complete" from "assistant arrived early".
|
//
|
||||||
|
// 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 (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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-3
@@ -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)');
|
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 event = { type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } };
|
||||||
const chunk = anthropicStreamJsonEventToIR(event, false);
|
const chunk = anthropicStreamJsonEventToIR(event, /* isFirstDelta */ false);
|
||||||
assert.equal(chunk, null, 'assistant aggregate event must return null (deltas already streamed)');
|
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)', () => {
|
it('41e-8: anthropicStreamJsonEventToIR — parse_error event → null (already logged)', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user