mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat(anthropic): stream-json + --system-prompt (ADR 0009 Amendment 1)
Replaces claude -p --output-format text spawn with claude (no -p) --output-format stream-json --verbose --system-prompt. Authority: - claude CLI v2.1.104 § --output-format stream-json (verified 2026-05-27 empirical test on PI231: NDJSON event stream emits without -p) - claude CLI v2.1.104 § --verbose (required companion to stream-json) - claude CLI v2.1.104 § --system-prompt (full default-prompt replacement, suppresses env-block + tool descriptions) - claude CLI v2.1.104 § --no-session-persistence - OLP ADR 0009 Amendment 1 — decision lock + value re-anchoring - OLP ALIGNMENT.md Rule 1 — provider plugin authority citation Four orthogonal values delivered: 1. Hallucination fix: model no longer claims server-side cwd / OS / tools (verified bot self-check produces "I don't have local env" instead of "/home/tlab/olp") 2. ~64% per-request cost reduction: empirical Sonnet 4.6 $0.0216 → $0.0078, from ~30% input-token reduction (16,601 → 10,700) 3. NDJSON observability: rate_limit_event + usage + cache stats per request now available (future audit/dashboard work) 4. Possible 30-60 day bridge for Anthropic 2026-06-15 billing split (uncertain per P0 spike — third-party-app classification clause) Per ADR 0009 Amendment 1 § "Caveats": this is NOT a substitute for Phase 7 @anthropic-ai/sandbox-runtime work; sandbox remains required for any cloud / multi-tenant deployment. Cache key composition unchanged (ADR 0005 IR-based hash; on-the-wire format is internal to the provider plugin). Tests: 771 → 790 (+19 new in Suite 41; 9 existing mocks updated to emit valid NDJSON stream_event format instead of raw text). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+388
-27
@@ -89,6 +89,12 @@ import anthropic, {
|
||||
healthCheck as anthropicHealthCheck,
|
||||
__setSpawnImpl,
|
||||
__resetSpawnImpl,
|
||||
// ADR 0009 Amendment 1 — stream-json transport
|
||||
OLP_SYSTEM_PROMPT_WRAPPER,
|
||||
extractSystemPrompt,
|
||||
buildCliArgs,
|
||||
parseStreamJsonLines,
|
||||
anthropicStreamJsonEventToIR,
|
||||
} from './lib/providers/anthropic.mjs';
|
||||
import codex, {
|
||||
irToCodex,
|
||||
@@ -809,6 +815,61 @@ function makeMockSpawn(stdoutChunks, exitCode = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fake spawn that emits proper NDJSON stream-json events then exits.
|
||||
* Used by Suite 6 (Anthropic plugin D4) and Suite 41 (ADR 0009 Amendment 1 tests).
|
||||
*
|
||||
* ADR 0009 Amendment 1: _spawnAndStream now parses NDJSON events from stdout.
|
||||
* Raw text chunks (as in makeMockSpawn) produce parse_error events → no delta content.
|
||||
* This helper emits content_block_delta stream_events so the assertion on content works.
|
||||
*
|
||||
* @param {string[]} textChunks — content strings to emit as content_block_delta events
|
||||
* @param {number} [exitCode=0]
|
||||
*/
|
||||
function makeMockSpawnNDJSON(textChunks, exitCode = 0) {
|
||||
return function mockSpawnNDJSONImpl(_bin, _args, _opts) {
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
setImmediate(async () => {
|
||||
// Emit system/init first (consumed, no yield)
|
||||
const initEvent = JSON.stringify({ type: 'system', subtype: 'init', cwd: '/tmp', tools: [] });
|
||||
proc.stdout.emit('data', Buffer.from(initEvent + '\n'));
|
||||
|
||||
// Emit each text chunk as a stream_event/content_block_delta
|
||||
for (const chunk of textChunks) {
|
||||
const deltaEvent = JSON.stringify({
|
||||
type: 'stream_event',
|
||||
event: { type: 'content_block_delta', delta: { type: 'text_delta', text: chunk } },
|
||||
});
|
||||
proc.stdout.emit('data', Buffer.from(deltaEvent + '\n'));
|
||||
}
|
||||
|
||||
// Emit result/success as terminal event
|
||||
const resultEvent = JSON.stringify({
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
result: textChunks.join(''),
|
||||
total_cost_usd: 0.0001,
|
||||
});
|
||||
proc.stdout.emit('data', Buffer.from(resultEvent + '\n'));
|
||||
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', exitCode, null);
|
||||
});
|
||||
},
|
||||
};
|
||||
proc.killed = false;
|
||||
proc.kill = () => {};
|
||||
return proc;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Suite D17: Alias-aware getProviderForModel (Finding 12 + 13) ─────────────
|
||||
//
|
||||
// Tests that getProviderForModel resolves aliases from models-registry.json
|
||||
@@ -1032,7 +1093,9 @@ describe('Anthropic plugin (D4)', () => {
|
||||
assert.ok(!prompt.includes('[Assistant]'));
|
||||
});
|
||||
|
||||
it('irToAnthropic: system + user → system annotation + user text', () => {
|
||||
it('irToAnthropic: system + user → system message SKIPPED in stdin (ADR 0009 Amendment 1)', () => {
|
||||
// ADR 0009 Amendment 1: role:system messages are extracted by extractSystemPrompt()
|
||||
// and passed via --system-prompt flag. They must NOT appear in the stdin prompt.
|
||||
const ir = makeIR({
|
||||
model: 'claude-sonnet-4-6',
|
||||
messages: [
|
||||
@@ -1041,7 +1104,10 @@ describe('Anthropic plugin (D4)', () => {
|
||||
],
|
||||
});
|
||||
const prompt = irToAnthropic(ir);
|
||||
assert.ok(prompt.includes('[System] You are a helper.'));
|
||||
// system message must NOT appear in stdin prompt (goes via --system-prompt instead)
|
||||
assert.ok(!prompt.includes('[System] You are a helper.'), 'system content must not leak to stdin');
|
||||
assert.ok(!prompt.includes('You are a helper.'), 'system content must not leak to stdin in any form');
|
||||
// user message must still be present
|
||||
assert.ok(prompt.includes('What is 2+2?'));
|
||||
});
|
||||
|
||||
@@ -1113,8 +1179,10 @@ describe('Anthropic plugin (D4)', () => {
|
||||
});
|
||||
|
||||
// ── Test 7: mock spawn — AsyncIterator yields correct IR chunks ───────
|
||||
it('spawn with mock: yields delta chunks then stop chunk', async () => {
|
||||
const fakeSpawn = makeMockSpawn(['Hello', ' world']);
|
||||
// ADR 0009 Amendment 1: spawn now uses NDJSON stream-json path.
|
||||
// Tests updated to use makeMockSpawnNDJSON which emits proper stream_event NDJSON.
|
||||
it('spawn with mock: yields delta chunks then stop chunk (NDJSON path)', async () => {
|
||||
const fakeSpawn = makeMockSpawnNDJSON(['Hello', ' world']);
|
||||
__setSpawnImpl(fakeSpawn);
|
||||
try {
|
||||
const ir = makeIR({
|
||||
@@ -1127,7 +1195,7 @@ describe('Anthropic plugin (D4)', () => {
|
||||
for await (const chunk of anthropic.spawn(ir, authCtx)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
// Should have 2 delta chunks + 1 stop chunk
|
||||
// Should have 2 delta chunks + 1 stop chunk from result event
|
||||
const deltas = chunks.filter(c => c.type === 'delta');
|
||||
const stops = chunks.filter(c => c.type === 'stop');
|
||||
assert.ok(deltas.length >= 1, `Expected at least 1 delta, got ${deltas.length}`);
|
||||
@@ -1139,8 +1207,8 @@ describe('Anthropic plugin (D4)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('spawn with mock: first delta chunk has role=assistant', async () => {
|
||||
const fakeSpawn = makeMockSpawn(['Test output']);
|
||||
it('spawn with mock: first delta chunk has role=assistant (NDJSON path)', async () => {
|
||||
const fakeSpawn = makeMockSpawnNDJSON(['Test output']);
|
||||
__setSpawnImpl(fakeSpawn);
|
||||
try {
|
||||
const ir = makeIR({
|
||||
@@ -5377,9 +5445,9 @@ describe('Fallback engine — HTTP integration (D9)', () => {
|
||||
soft_triggers: {},
|
||||
});
|
||||
|
||||
// Anthropic mock: emits 2 raw text chunks then exits non-zero (cleanup error).
|
||||
// The anthropic plugin processes raw stdout as text → IR delta chunks.
|
||||
// exit code 1 after yielding content → Case B: SPAWN_FAILED with chunks.length>0.
|
||||
// Anthropic mock: emits 2 NDJSON content_block_delta events then exits non-zero.
|
||||
// ADR 0009 Amendment 1: stream-json path — content must be valid NDJSON to yield
|
||||
// delta chunks. exit code 1 after yielding content → Case B: SPAWN_FAILED with chunks.length>0.
|
||||
__setSpawnImpl(function (_bin, _args, _opts) {
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
@@ -5388,9 +5456,9 @@ describe('Fallback engine — HTTP integration (D9)', () => {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
setImmediate(() => {
|
||||
// Emit 2 content chunks as raw text (anthropic --output-format text)
|
||||
proc.stdout.emit('data', Buffer.from('Hello '));
|
||||
proc.stdout.emit('data', Buffer.from('world'));
|
||||
// Emit 2 NDJSON content_block_delta stream_events (ADR 0009 Amendment 1)
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello ' } } }) + '\n'));
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'world' } } }) + '\n'));
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
// Non-zero exit AFTER emitting content → SPAWN_FAILED, chunks.length=2
|
||||
@@ -5479,7 +5547,8 @@ describe('Fallback engine — HTTP integration (D9)', () => {
|
||||
soft_triggers: {},
|
||||
});
|
||||
|
||||
// Anthropic mock: emits 1 raw text chunk then exits non-zero
|
||||
// Anthropic mock: emits 1 NDJSON content_block_delta event then exits non-zero
|
||||
// ADR 0009 Amendment 1: stream-json path — content must be valid NDJSON.
|
||||
__setSpawnImpl(function (_bin, _args, _opts) {
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
@@ -5488,7 +5557,7 @@ describe('Fallback engine — HTTP integration (D9)', () => {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
setImmediate(() => {
|
||||
proc.stdout.emit('data', Buffer.from('Partial answer'));
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Partial answer' } } }) + '\n'));
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 1, null); // exit 1 after content → Case B
|
||||
@@ -5544,7 +5613,8 @@ describe('Fallback engine — HTTP integration (D9)', () => {
|
||||
soft_triggers: {},
|
||||
});
|
||||
|
||||
// Reuse the D16 Case-B single-hop mock: emit 1 raw text chunk, then exit-1.
|
||||
// D39: emit 1 NDJSON content_block_delta event then exit-1 (Case B salvage path).
|
||||
// ADR 0009 Amendment 1: stream-json path — content must be valid NDJSON.
|
||||
__setSpawnImpl(function (_bin, _args, _opts) {
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
@@ -5553,7 +5623,7 @@ describe('Fallback engine — HTTP integration (D9)', () => {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
setImmediate(() => {
|
||||
proc.stdout.emit('data', Buffer.from('D39-evict-log-content'));
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'D39-evict-log-content' } } }) + '\n'));
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 1, null); // Case B salvage path
|
||||
@@ -5646,8 +5716,9 @@ describe('Fallback engine — HTTP integration (D9)', () => {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
setImmediate(() => {
|
||||
// Case B: emit partial content then exit non-zero on every spawn.
|
||||
proc.stdout.emit('data', Buffer.from('D39-sticky-content'));
|
||||
// Case B: emit NDJSON partial content then exit non-zero on every spawn.
|
||||
// ADR 0009 Amendment 1: stream-json path requires valid NDJSON.
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'D39-sticky-content' } } }) + '\n'));
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 1, null);
|
||||
@@ -6611,8 +6682,9 @@ describe('Streaming cache-miss real-time (Suite 15)', () => {
|
||||
});
|
||||
|
||||
it('15a: streaming cache-miss → res.write fires per chunk, not all-at-once', async () => {
|
||||
// Mock: emits 3 deltas with a 20ms gap between each, then stop.
|
||||
// Mock: emits 3 NDJSON deltas with a 20ms gap between each, then stop.
|
||||
// We verify by recording arrival timestamps on the client side.
|
||||
// ADR 0009 Amendment 1: stream-json path — each chunk is a valid NDJSON stream_event.
|
||||
const DELTA_GAP_MS = 20;
|
||||
|
||||
__setSpawnImpl(function (_bin, _args, _opts) {
|
||||
@@ -6622,15 +6694,18 @@ describe('Streaming cache-miss real-time (Suite 15)', () => {
|
||||
proc.stdin = {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
// Emit 3 deltas with gaps
|
||||
// Emit 3 NDJSON content_block_delta events with gaps
|
||||
let idx = 0;
|
||||
const texts = ['chunk1', 'chunk2', 'chunk3'];
|
||||
const emitNext = () => {
|
||||
if (idx < texts.length) {
|
||||
proc.stdout.emit('data', Buffer.from(texts[idx]));
|
||||
const event = JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: texts[idx] } } });
|
||||
proc.stdout.emit('data', Buffer.from(event + '\n'));
|
||||
idx++;
|
||||
setTimeout(emitNext, DELTA_GAP_MS);
|
||||
} else {
|
||||
// Emit result/success to close the stream
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'result', subtype: 'success', is_error: false, result: texts.join('') }) + '\n'));
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 0, null);
|
||||
@@ -6709,7 +6784,9 @@ describe('Streaming cache-miss real-time (Suite 15)', () => {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
setImmediate(() => {
|
||||
proc.stdout.emit('data', Buffer.from('cached-content'));
|
||||
// ADR 0009 Amendment 1: emit NDJSON stream_event + result event
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'cached-content' } } }) + '\n'));
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'result', subtype: 'success', is_error: false, result: 'cached-content' }) + '\n'));
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 0, null);
|
||||
@@ -6778,7 +6855,9 @@ describe('Streaming cache-miss real-time (Suite 15)', () => {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
setImmediate(() => {
|
||||
proc.stdout.emit('data', Buffer.from('multihop-content'));
|
||||
// ADR 0009 Amendment 1: emit NDJSON stream_event + result event
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'multihop-content' } } }) + '\n'));
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'result', subtype: 'success', is_error: false, result: 'multihop-content' }) + '\n'));
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 0, null);
|
||||
@@ -7947,13 +8026,15 @@ describe('Spawn timeout (Suite 16)', () => {
|
||||
proc.stdin = {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
// Emit data + close via setImmediate so the event handlers (registered
|
||||
// Emit NDJSON data + close via setImmediate so the event handlers (registered
|
||||
// AFTER stdin.end() in the plugin body) are already attached when
|
||||
// these events fire. All three items land in the same setImmediate
|
||||
// callback, so they're all in chunks[] before the drain loop resumes.
|
||||
// ADR 0009 Amendment 1: must be valid NDJSON stream_events so that the
|
||||
// first chunk yields a delta (enabling the D24 race to occur).
|
||||
setImmediate(() => {
|
||||
proc.stdout.emit('data', Buffer.from('partial-chunk-1'));
|
||||
proc.stdout.emit('data', Buffer.from('partial-chunk-2'));
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'partial-chunk-1' } } }) + '\n'));
|
||||
proc.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'partial-chunk-2' } } }) + '\n'));
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 0, null); // exitCode=0, done=true
|
||||
});
|
||||
@@ -17302,3 +17383,283 @@ describe('Suite 40 — F4 CLI/plugin quota_v2 migration + v1.x #7 AUTH_MISSING t
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Suite 41 — ADR 0009 Amendment 1: stream-json transport (Phase 6) ────────
|
||||
//
|
||||
// Tests for: extractSystemPrompt, buildCliArgs (no -p), parseStreamJsonLines,
|
||||
// anthropicStreamJsonEventToIR, and a full end-to-end mock-spawn integration.
|
||||
//
|
||||
// Authority: claude CLI v2.1.104 § --output-format stream-json / --verbose /
|
||||
// --system-prompt / --no-session-persistence (verified on PI231 2026-05-27).
|
||||
// OLP ADR 0009 Amendment 1 — decision lock + value re-anchoring.
|
||||
// OLP ALIGNMENT.md Rule 1 — provider plugin authority citation.
|
||||
|
||||
describe('Suite 41 — ADR 0009 Amendment 1: stream-json transport (Phase 6)', () => {
|
||||
|
||||
// ── 41a: extractSystemPrompt ──────────────────────────────────────────
|
||||
|
||||
it('41a-1: extractSystemPrompt — no client system messages → OLP_SYSTEM_PROMPT_WRAPPER alone', () => {
|
||||
const ir = makeIR({
|
||||
model: 'claude-sonnet-4-6',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
});
|
||||
const result = extractSystemPrompt(ir);
|
||||
assert.equal(result, OLP_SYSTEM_PROMPT_WRAPPER,
|
||||
'Without system messages, result must be exactly OLP_SYSTEM_PROMPT_WRAPPER');
|
||||
assert.ok(result.includes('OLP HTTP proxy'),
|
||||
'OLP_SYSTEM_PROMPT_WRAPPER must mention "OLP HTTP proxy"');
|
||||
assert.ok(result.includes('Do not infer or invent'),
|
||||
'OLP_SYSTEM_PROMPT_WRAPPER must include the anti-hallucination clause');
|
||||
});
|
||||
|
||||
it('41a-2: extractSystemPrompt — one client system message → wrapper + blank + client content', () => {
|
||||
const ir = makeIR({
|
||||
model: 'claude-sonnet-4-6',
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a pirate.' },
|
||||
{ role: 'user', content: 'Ahoy' },
|
||||
],
|
||||
});
|
||||
const result = extractSystemPrompt(ir);
|
||||
assert.ok(result.startsWith(OLP_SYSTEM_PROMPT_WRAPPER),
|
||||
'Result must start with OLP_SYSTEM_PROMPT_WRAPPER');
|
||||
assert.ok(result.includes('\n\nYou are a pirate.'),
|
||||
'Client system content must be appended after blank line');
|
||||
// OLP wrapper and client content separated by exactly \n\n
|
||||
assert.equal(result, `${OLP_SYSTEM_PROMPT_WRAPPER}\n\nYou are a pirate.`);
|
||||
});
|
||||
|
||||
it('41a-3: extractSystemPrompt — multiple client system messages → wrapper + blank + concatenated content', () => {
|
||||
const ir = makeIR({
|
||||
model: 'claude-sonnet-4-6',
|
||||
messages: [
|
||||
{ role: 'system', content: 'Be concise.' },
|
||||
{ role: 'system', content: 'Speak in French.' },
|
||||
{ role: 'user', content: 'Bonjour' },
|
||||
],
|
||||
});
|
||||
const result = extractSystemPrompt(ir);
|
||||
assert.ok(result.startsWith(OLP_SYSTEM_PROMPT_WRAPPER),
|
||||
'Result must start with OLP_SYSTEM_PROMPT_WRAPPER');
|
||||
assert.ok(result.includes('Be concise.'), 'First system message must be included');
|
||||
assert.ok(result.includes('Speak in French.'), 'Second system message must be included');
|
||||
// Multiple system messages joined with \n\n
|
||||
assert.ok(result.includes('Be concise.\n\nSpeak in French.'),
|
||||
'Multiple system messages must be joined with \\n\\n');
|
||||
});
|
||||
|
||||
// ── 41b: irToAnthropic skips role:system (ADR 0009 Amendment 1) ──────
|
||||
|
||||
it('41b: irToAnthropic — role:system messages are skipped (go via --system-prompt)', () => {
|
||||
const ir = makeIR({
|
||||
model: 'claude-sonnet-4-6',
|
||||
messages: [
|
||||
{ role: 'system', content: 'Be a weather bot.' },
|
||||
{ role: 'user', content: 'What is the weather?' },
|
||||
{ role: 'assistant', content: 'Sunny.' },
|
||||
{ role: 'user', content: 'Thanks.' },
|
||||
],
|
||||
});
|
||||
const prompt = irToAnthropic(ir);
|
||||
// System messages must NOT appear in the stdin prompt
|
||||
assert.ok(!prompt.includes('[System]'), 'irToAnthropic must not emit [System] prefix (ADR 0009 Amendment 1)');
|
||||
assert.ok(!prompt.includes('Be a weather bot.'), 'System content must not leak to stdin');
|
||||
// Other roles must still appear
|
||||
assert.ok(prompt.includes('What is the weather?'), 'User message must be present');
|
||||
assert.ok(prompt.includes('[Assistant] Sunny.'), 'Assistant message must be annotated');
|
||||
assert.ok(prompt.includes('Thanks.'), 'Second user message must be present');
|
||||
});
|
||||
|
||||
// ── 41c: buildCliArgs ─────────────────────────────────────────────────
|
||||
|
||||
it('41c: buildCliArgs — no -p flag, includes stream-json + verbose + system-prompt', () => {
|
||||
const args = buildCliArgs('claude-sonnet-4-6', 'You are a test assistant.');
|
||||
// Must NOT include -p
|
||||
assert.ok(!args.includes('-p'), 'buildCliArgs must NOT include -p (ADR 0009 Amendment 1)');
|
||||
// Must include all required flags
|
||||
assert.ok(args.includes('--output-format'), 'must include --output-format');
|
||||
assert.ok(args.includes('stream-json'), 'must use stream-json output format');
|
||||
assert.ok(args.includes('--verbose'), 'must include --verbose');
|
||||
assert.ok(args.includes('--no-session-persistence'), 'must include --no-session-persistence');
|
||||
assert.ok(args.includes('--system-prompt'), 'must include --system-prompt');
|
||||
assert.ok(args.includes('You are a test assistant.'), 'system prompt text must be in args');
|
||||
// Model flag
|
||||
assert.ok(args.includes('--model'), 'must include --model');
|
||||
assert.ok(args.includes('claude-sonnet-4-6'), 'must include model value');
|
||||
});
|
||||
|
||||
// ── 41d: parseStreamJsonLines ─────────────────────────────────────────
|
||||
|
||||
it('41d-1: parseStreamJsonLines — single complete line → 1 event + empty remainder', () => {
|
||||
const line = JSON.stringify({ type: 'system', subtype: 'init' });
|
||||
const { events, remainder } = parseStreamJsonLines(line + '\n');
|
||||
assert.equal(events.length, 1, 'Should parse 1 event');
|
||||
assert.equal(events[0].type, 'system', 'Event type must be system');
|
||||
assert.equal(remainder, '', 'Remainder must be empty after complete line');
|
||||
});
|
||||
|
||||
it('41d-2: parseStreamJsonLines — incomplete trailing line → events from complete lines + remainder', () => {
|
||||
const line1 = JSON.stringify({ type: 'system', subtype: 'init' });
|
||||
const line2Partial = '{"type":"stream_eve'; // incomplete
|
||||
const input = line1 + '\n' + line2Partial;
|
||||
const { events, remainder } = parseStreamJsonLines(input);
|
||||
assert.equal(events.length, 1, 'Should parse only the complete line');
|
||||
assert.equal(events[0].type, 'system');
|
||||
assert.equal(remainder, line2Partial, 'Incomplete line must be the remainder');
|
||||
});
|
||||
|
||||
it('41d-3: parseStreamJsonLines — JSON parse error on one line → other lines parsed, error event returned', () => {
|
||||
const line1 = JSON.stringify({ type: 'system', subtype: 'init' });
|
||||
const badLine = 'not-valid-json{{{';
|
||||
const line3 = JSON.stringify({ type: 'result', subtype: 'success' });
|
||||
const input = line1 + '\n' + badLine + '\n' + line3 + '\n';
|
||||
const { events, remainder } = parseStreamJsonLines(input);
|
||||
assert.equal(events.length, 3, 'Should return 3 events (including parse_error)');
|
||||
assert.equal(events[0].type, 'system', 'First event: system/init');
|
||||
assert.equal(events[1].type, 'parse_error', 'Second event: parse_error for bad line');
|
||||
assert.equal(events[1].raw, badLine, 'parse_error.raw must be the bad line');
|
||||
assert.equal(events[2].type, 'result', 'Third event: result (parsed successfully)');
|
||||
assert.equal(remainder, '', 'Remainder must be empty');
|
||||
});
|
||||
|
||||
it('41d-4: parseStreamJsonLines — blank lines between events are skipped', () => {
|
||||
const line1 = JSON.stringify({ type: 'system', subtype: 'init' });
|
||||
const line2 = JSON.stringify({ type: 'result', subtype: 'success' });
|
||||
// Extra blank line between them (common in some NDJSON writers)
|
||||
const input = line1 + '\n\n' + line2 + '\n';
|
||||
const { events, remainder } = parseStreamJsonLines(input);
|
||||
assert.equal(events.length, 2, 'Blank lines must be skipped; 2 events expected');
|
||||
assert.equal(events[0].type, 'system');
|
||||
assert.equal(events[1].type, 'result');
|
||||
});
|
||||
|
||||
// ── 41e: anthropicStreamJsonEventToIR ────────────────────────────────
|
||||
|
||||
it('41e-1: anthropicStreamJsonEventToIR — content_block_delta → delta chunk with text', () => {
|
||||
const event = {
|
||||
type: 'stream_event',
|
||||
event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello' } },
|
||||
};
|
||||
const chunk = anthropicStreamJsonEventToIR(event, false);
|
||||
assert.ok(chunk !== null, 'content_block_delta must yield an IR chunk');
|
||||
assert.equal(chunk.type, 'delta', 'IR chunk type must be "delta"');
|
||||
assert.equal(chunk.content, 'Hello', 'IR chunk content must match text_delta.text');
|
||||
assert.ok(!('role' in chunk), 'Non-first delta must not include role');
|
||||
});
|
||||
|
||||
it('41e-2: anthropicStreamJsonEventToIR — first content_block_delta includes role=assistant', () => {
|
||||
const event = {
|
||||
type: 'stream_event',
|
||||
event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello' } },
|
||||
};
|
||||
const chunk = anthropicStreamJsonEventToIR(event, true /* isFirstDelta */);
|
||||
assert.equal(chunk.type, 'delta');
|
||||
assert.equal(chunk.content, 'Hello');
|
||||
assert.equal(chunk.role, 'assistant', 'First delta must include role=assistant');
|
||||
});
|
||||
|
||||
it('41e-3: anthropicStreamJsonEventToIR — result success → stop chunk', () => {
|
||||
const event = { type: 'result', subtype: 'success', is_error: false, result: 'hello', total_cost_usd: 0.001 };
|
||||
const chunk = anthropicStreamJsonEventToIR(event, false);
|
||||
assert.ok(chunk !== null, 'result/success must yield an IR stop chunk');
|
||||
assert.equal(chunk.type, 'stop', 'IR chunk type must be "stop"');
|
||||
assert.equal(chunk.finish_reason, 'stop', 'finish_reason must be "stop"');
|
||||
});
|
||||
|
||||
it('41e-4: anthropicStreamJsonEventToIR — result is_error → throws ProviderError', () => {
|
||||
const event = { type: 'result', is_error: true, error_message: 'rate limited by upstream' };
|
||||
assert.throws(
|
||||
() => anthropicStreamJsonEventToIR(event, false),
|
||||
err => err instanceof ProviderError && err.code === 'PROVIDER_ERROR',
|
||||
'result with is_error must throw ProviderError(PROVIDER_ERROR)',
|
||||
);
|
||||
});
|
||||
|
||||
it('41e-5: anthropicStreamJsonEventToIR — system/init → null (consumed)', () => {
|
||||
const event = { type: 'system', subtype: 'init', cwd: '/home/user', tools: [] };
|
||||
const chunk = anthropicStreamJsonEventToIR(event, false);
|
||||
assert.equal(chunk, null, 'system/init must return null (consumed, no yield)');
|
||||
});
|
||||
|
||||
it('41e-6: anthropicStreamJsonEventToIR — rate_limit_event → null (consumed)', () => {
|
||||
const event = { type: 'rate_limit_event', rate_limit_info: { status: 'allowed' } };
|
||||
const chunk = anthropicStreamJsonEventToIR(event, false);
|
||||
assert.equal(chunk, null, 'rate_limit_event must return null (future audit/dashboard work)');
|
||||
});
|
||||
|
||||
it('41e-7: anthropicStreamJsonEventToIR — assistant (aggregate) → 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)');
|
||||
});
|
||||
|
||||
it('41e-8: anthropicStreamJsonEventToIR — parse_error event → null (already logged)', () => {
|
||||
const event = { type: 'parse_error', raw: 'bad json' };
|
||||
const chunk = anthropicStreamJsonEventToIR(event, false);
|
||||
assert.equal(chunk, null, 'parse_error must return null');
|
||||
});
|
||||
|
||||
// ── 41f: Full end-to-end mock spawn integration ───────────────────────
|
||||
|
||||
it('41f: full NDJSON stream-in → IR chunks-out integration via __setSpawnImpl', async () => {
|
||||
// Simulates real claude CLI output: system/init + content_block_deltas + result/success.
|
||||
// Verifies the complete pipeline: stdin write → NDJSON parse → IR yield → stop.
|
||||
const fakeSpawn = makeMockSpawnNDJSON(['Answer: ', '42']);
|
||||
__setSpawnImpl(fakeSpawn);
|
||||
try {
|
||||
const ir = makeIR({
|
||||
model: 'claude-sonnet-4-6',
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'system', content: 'Be precise.' },
|
||||
{ role: 'user', content: 'What is 6 × 7?' },
|
||||
],
|
||||
});
|
||||
const authCtx = { accessToken: '<fake-oauth-token>' };
|
||||
const chunks = [];
|
||||
for await (const chunk of anthropic.spawn(ir, authCtx)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Expected: 2 delta chunks + 1 stop chunk from result event
|
||||
const deltas = chunks.filter(c => c.type === 'delta');
|
||||
const stops = chunks.filter(c => c.type === 'stop');
|
||||
assert.equal(deltas.length, 2, `Expected 2 deltas, got ${deltas.length}`);
|
||||
assert.equal(stops.length, 1, `Expected 1 stop, got ${stops.length}`);
|
||||
|
||||
// Content must be the streamed text
|
||||
const content = deltas.map(c => c.content).join('');
|
||||
assert.equal(content, 'Answer: 42');
|
||||
|
||||
// First delta must carry role=assistant
|
||||
assert.equal(deltas[0].role, 'assistant', 'First delta must include role=assistant');
|
||||
// Subsequent deltas must not carry role
|
||||
assert.ok(!('role' in deltas[1]), 'Subsequent deltas must not carry role');
|
||||
|
||||
// Stop must have finish_reason='stop'
|
||||
assert.equal(stops[0].finish_reason, 'stop');
|
||||
} finally {
|
||||
__resetSpawnImpl();
|
||||
}
|
||||
});
|
||||
|
||||
it('41g: buildCliArgs arg order check — model flag before output-format (predictable arg order)', () => {
|
||||
const args = buildCliArgs('claude-opus-4-7', 'Test prompt');
|
||||
const modelIdx = args.indexOf('--model');
|
||||
const formatIdx = args.indexOf('--output-format');
|
||||
const verboseIdx = args.indexOf('--verbose');
|
||||
const nspIdx = args.indexOf('--no-session-persistence');
|
||||
const spIdx = args.indexOf('--system-prompt');
|
||||
|
||||
assert.ok(modelIdx >= 0, '--model must be present');
|
||||
assert.ok(formatIdx >= 0, '--output-format must be present');
|
||||
assert.ok(verboseIdx >= 0, '--verbose must be present');
|
||||
assert.ok(nspIdx >= 0, '--no-session-persistence must be present');
|
||||
assert.ok(spIdx >= 0, '--system-prompt must be present');
|
||||
|
||||
// --no-session-persistence must come before --system-prompt
|
||||
assert.ok(nspIdx < spIdx, '--no-session-persistence must precede --system-prompt');
|
||||
// model value must follow immediately after --model
|
||||
assert.equal(args[modelIdx + 1], 'claude-opus-4-7', '--model value must follow the flag');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user