mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +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:
+286
-73
@@ -2,22 +2,40 @@
|
||||
* lib/providers/anthropic.mjs — Anthropic Claude provider plugin
|
||||
*
|
||||
* Authority: OLP ALIGNMENT.md § Authority 1 — anthropic plugin governed by
|
||||
* `claude -p` from `@anthropic-ai/claude-code`.
|
||||
* OCP server.mjs inherits cli.js 2.1.89 audit pin at fork (per ALIGNMENT.md
|
||||
* § Provider authority pins).
|
||||
* D26 round-3 F18 observation: @anthropic-ai/claude-code v2.1.89 confirmed
|
||||
* present at D4 implementation (captured at D4 implementation per ALIGNMENT.md
|
||||
* Rule 5 — the circular ALIGNMENT.md ↔ plugin header citation is resolved by
|
||||
* this in-plugin record of the OLP-side observation).
|
||||
* `claude` CLI from `@anthropic-ai/claude-code`.
|
||||
* D36 #15: See docs/provider-audits/anthropic.md for the version-capture
|
||||
* artifact (single living document — captured 2026-05-24; re-capture at every
|
||||
* plugin touch or annual audit). The artifact records the live `claude --version`
|
||||
* today (v2.1.132) versus the plugin pin (v2.1.89, D4) and verifies that the
|
||||
* load-bearing flags (-p, --output-format, --no-session-persistence, --model,
|
||||
* --debug) are all still present and semantically unchanged in the current binary.
|
||||
* artifact (single living document; re-capture at every plugin touch or annual
|
||||
* audit).
|
||||
*
|
||||
* ADR 0009 Amendment 1 (2026-05-27) — stream-json transport replaces -p text:
|
||||
* The spawn mode was changed from `claude -p --output-format text` to
|
||||
* `claude --output-format stream-json --verbose --no-session-persistence
|
||||
* --system-prompt <OLP_SYSTEM_PROMPT_WRAPPER>`.
|
||||
*
|
||||
* Authorities (all verified live on PI231, claude CLI v2.1.104, 2026-05-27):
|
||||
* - claude CLI v2.1.104 § --output-format stream-json (NDJSON event stream)
|
||||
* - claude CLI v2.1.104 § --verbose (required companion; without it stream-json
|
||||
* produces only the final result event)
|
||||
* - claude CLI v2.1.104 § --system-prompt (full replacement of default system
|
||||
* prompt; suppresses env-block + tool descriptions injected by claude CLI)
|
||||
* - claude CLI v2.1.104 § --no-session-persistence (stateless per-spawn)
|
||||
* - 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 cwd / OS / tool names.
|
||||
* 2. ~64% per-request cost reduction: empirical Sonnet 4.6 $0.0216 → $0.0078,
|
||||
* from ~30% input-token reduction (16,601 → 10,700 tokens).
|
||||
* 3. NDJSON observability: rate_limit_event + usage + cache stats per request.
|
||||
* 4. Possible 30-60 day bridge for Anthropic 2026-06-15 billing split.
|
||||
*
|
||||
* Version pin guidance (ADR 0009 Amendment 1 § "Caveats" point 4):
|
||||
* stream-json without -p confirmed on v2.1.104. Future versions may tighten
|
||||
* this; the plugin emits a server-log warning if `claude --version` falls
|
||||
* outside v2.1.100–v2.1.149. Hard failure is NOT required; warn is sufficient.
|
||||
*
|
||||
* Spawn pattern ported from:
|
||||
* OCP server.mjs:384-414 (buildCliArgs — -p / --model / --output-format / --no-session-persistence)
|
||||
* OCP server.mjs:384-414 (buildCliArgs)
|
||||
* OCP server.mjs:480-607 (spawnClaudeProcess — env cleanup, spawn, stdin write)
|
||||
* OCP server.mjs:422-468 (messagesToPrompt — text serialization)
|
||||
*
|
||||
@@ -28,26 +46,15 @@
|
||||
*
|
||||
* Contract version: 1.0 (ADR 0002 § "Provider contract (v1.0 interface)" + D4 contractVersion fold-in)
|
||||
*
|
||||
* Status at D4: CANDIDATE. Not yet Enabled per ALIGNMENT.md § Provider Inventory.
|
||||
* The plugin is present in STATIC_REGISTRY but enabled: false is the default config.
|
||||
* POST /v1/chat/completions continues to return 503 until D5 flips the enabled flag
|
||||
* in ~/.olp/config.json after E2E audit passes.
|
||||
*
|
||||
* Lossy translations (per ADR 0003 § "Lossy-translation documentation requirement"):
|
||||
* - `response_format: json_object` — Anthropic `claude -p` does not natively honor this
|
||||
* field; a system-prompt augmentation is injected ("Reply with valid JSON only.").
|
||||
* - `top_p` — not mapped to a `claude -p` flag; dropped silently. `claude -p` does not
|
||||
* expose --top-p.
|
||||
* - `tool_choice: "required"` — not a `claude -p` flag; dropped.
|
||||
* - Request-level `tools[]` — `claude -p --output-format text` is a text-in/text-out
|
||||
* CLI and does not consume structured tool definitions on the wire. The array is
|
||||
* silently dropped; if a caller wants tool-use they should pre-prompt the model
|
||||
* about the tools in the system message. ALIGNMENT.md Rule 2 forbids inventing
|
||||
* a wire format Anthropic's CLI does not accept.
|
||||
* - Assistant-message `tool_calls` and `tool_call_id` (IR messages representing prior
|
||||
* turns where the assistant invoked a tool) — same reason as above; dropped.
|
||||
* If a multi-turn conversation includes tool-call history, the textual content is
|
||||
* preserved but the structured call metadata is lost.
|
||||
* - `response_format: json_object` — `--system-prompt` augmented with JSON instruction.
|
||||
* - `top_p` — not mapped; dropped silently. `claude` does not expose --top-p.
|
||||
* - `tool_choice: "required"` — not a `claude` flag; dropped.
|
||||
* - Request-level `tools[]` — stream-json mode does not consume structured tool defs
|
||||
* on the wire. Array silently dropped; caller should pre-prompt in system message.
|
||||
* ALIGNMENT.md Rule 2 forbids inventing a wire format Anthropic's CLI doesn't accept.
|
||||
* - Assistant-message `tool_calls` and `tool_call_id` — same reason; dropped.
|
||||
* Textual content preserved, structured call metadata lost.
|
||||
*
|
||||
* Quota status: D80 (Phase 5) — live plan-usage probe via POST /v1/messages.
|
||||
* Authority: ALIGNMENT.md Class-specific Exception §1 + ADR 0002 Amendment 8
|
||||
@@ -84,6 +91,40 @@ function resolveClaudeBin() {
|
||||
return process.env.OLP_CLAUDE_BIN || 'claude';
|
||||
}
|
||||
|
||||
// ── OLP system prompt wrapper (ADR 0009 Amendment 1) ─────────────────────
|
||||
// Injected via `--system-prompt` flag to replace claude CLI's default system
|
||||
// prompt (which normally includes cwd, OS, tool descriptions, and git status —
|
||||
// all irrelevant and potentially misleading when the model is accessed via the
|
||||
// OLP HTTP proxy).
|
||||
//
|
||||
// Authority: claude CLI v2.1.104 § --system-prompt (full default-prompt
|
||||
// replacement verified on PI231 2026-05-27).
|
||||
// ADR 0009 Amendment 1 § "OLP system prompt wrapper".
|
||||
export const OLP_SYSTEM_PROMPT_WRAPPER = `You are accessed via the OLP HTTP proxy. You do NOT have access to any local filesystem, working directory, shell, git status, or machine environment. Do not infer or invent such information from any context you observe. Respond only based on the conversation provided.`;
|
||||
|
||||
/**
|
||||
* Construct the full system-prompt string to pass via --system-prompt.
|
||||
*
|
||||
* Always starts with OLP_SYSTEM_PROMPT_WRAPPER. If the IR request contains
|
||||
* role:system messages, their content is appended after a blank line
|
||||
* (concatenated with "\n\n" if multiple).
|
||||
*
|
||||
* ADR 0009 Amendment 1 § "OLP system prompt wrapper".
|
||||
*
|
||||
* @param {object} irRequest
|
||||
* @returns {string} system prompt string (never null)
|
||||
*/
|
||||
export function extractSystemPrompt(irRequest) {
|
||||
const systemMessages = (irRequest.messages ?? []).filter(m => m.role === 'system');
|
||||
if (systemMessages.length === 0) {
|
||||
return OLP_SYSTEM_PROMPT_WRAPPER;
|
||||
}
|
||||
const clientContent = systemMessages.map(m =>
|
||||
typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
|
||||
).join('\n\n');
|
||||
return `${OLP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`;
|
||||
}
|
||||
|
||||
// ── Auth artifact reading ─────────────────────────────────────────────────
|
||||
// Ported from OCP server.mjs:864-888 (getOAuthCredentials).
|
||||
// Priority order (matching OCP exactly):
|
||||
@@ -539,25 +580,31 @@ async function _probeOnce(creds) {
|
||||
|
||||
// ── IR → prompt text serialization ───────────────────────────────────────
|
||||
// OCP server.mjs:422-468 (messagesToPrompt) — no session management in OLP;
|
||||
// we always pass the full serialized messages as stdin to `claude -p`.
|
||||
// we always pass the full serialized messages as stdin to `claude` (no -p).
|
||||
//
|
||||
// `claude -p` reads its prompt from stdin when no prompt argument is given.
|
||||
// OCP server.mjs:542 confirms: `proc.stdin.write(prompt); proc.stdin.end();`
|
||||
// The format OCP uses is: system → "[System] <text>", assistant → "[Assistant] <text>",
|
||||
// user → raw text. We port the same format.
|
||||
// ADR 0009 Amendment 1: role:system messages are now extracted by
|
||||
// `extractSystemPrompt()` and injected via `--system-prompt` flag (which fully
|
||||
// replaces claude CLI's default prompt). They are SKIPPED here to avoid
|
||||
// double-injection. All other roles (user, assistant, tool) are serialized to
|
||||
// text as before and written to stdin.
|
||||
//
|
||||
// `claude` reads its prompt from stdin when --output-format stream-json is used
|
||||
// without a prompt argument.
|
||||
//
|
||||
// ADR 0003 § Translation direction model: the plugin owns irToNative.
|
||||
export function irToAnthropic(irRequest) {
|
||||
const parts = [];
|
||||
|
||||
for (const msg of irRequest.messages) {
|
||||
// ADR 0009 Amendment 1: system messages routed via --system-prompt flag;
|
||||
// skip here to avoid double-injection into the stdin prompt.
|
||||
if (msg.role === 'system') continue;
|
||||
|
||||
const text = typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content);
|
||||
|
||||
if (msg.role === 'system') {
|
||||
parts.push(`[System] ${text}`);
|
||||
} else if (msg.role === 'assistant') {
|
||||
if (msg.role === 'assistant') {
|
||||
parts.push(`[Assistant] ${text}`);
|
||||
} else if (msg.role === 'tool') {
|
||||
// Tool result — pass as annotated user turn so the model knows it's a tool response.
|
||||
@@ -570,6 +617,8 @@ export function irToAnthropic(irRequest) {
|
||||
}
|
||||
|
||||
// Lossy: response_format json_object → system-prompt augmentation.
|
||||
// Note: this injects a [System] annotation into the stdin prompt (legacy path).
|
||||
// A future ADR 0009 follow-up may route this via extractSystemPrompt() instead.
|
||||
// ADR 0003 § Lossy-translation documentation requirement.
|
||||
if (irRequest.response_format?.type === 'json_object') {
|
||||
parts.unshift('[System] Reply with valid JSON only. Do not include any prose outside the JSON structure.');
|
||||
@@ -578,17 +627,14 @@ export function irToAnthropic(irRequest) {
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
// ── Anthropic SSE chunk → IR ResponseChunk ────────────────────────────────
|
||||
// `claude -p --output-format text` emits raw text to stdout (not NDJSON/SSE).
|
||||
// This matches OCP server.mjs:735-748: each `proc.stdout.on('data', d => ...)` chunk
|
||||
// is raw text content — no JSON envelope. The plugin converts raw text chunks to
|
||||
// IR delta chunks, and produces a synthetic stop chunk at close.
|
||||
// ── Anthropic raw-text chunk → IR ResponseChunk (LEGACY) ─────────────────
|
||||
// @legacy: Used by the former `claude -p --output-format text` spawn path.
|
||||
// ADR 0009 Amendment 1 replaced that path with stream-json NDJSON parsing.
|
||||
// This function is kept exported for backward-compatibility with any tests
|
||||
// that still exercise the old text-chunk path directly. New code uses
|
||||
// `anthropicStreamJsonEventToIR()` instead.
|
||||
//
|
||||
// For streaming: each text chunk → IRResponseChunk { type: 'delta', content: <text> }
|
||||
// For non-streaming: the full accumulated stdout → single text → emit delta + stop.
|
||||
//
|
||||
// `claude -p --output-format json` would emit a JSON envelope, but OCP uses `text`
|
||||
// (OCP server.mjs:385: "--output-format", "text"), so we port the same.
|
||||
// Authority: OCP server.mjs:735-748 (raw text chunk pattern).
|
||||
export function anthropicChunkToIR(rawText, isFirstChunk = false) {
|
||||
return {
|
||||
type: 'delta',
|
||||
@@ -605,21 +651,151 @@ export function anthropicStopToIR(finishReason = 'stop') {
|
||||
};
|
||||
}
|
||||
|
||||
// ── NDJSON line buffer parser ─────────────────────────────────────────────
|
||||
// Splits a buffered string on newlines. Returns complete JSON-parsed events
|
||||
// plus the trailing incomplete line as `remainder` for the next data chunk.
|
||||
//
|
||||
// ADR 0009 Amendment 1 — stream-json NDJSON event handling.
|
||||
// Authority: claude CLI v2.1.104 § --output-format stream-json (each event is
|
||||
// emitted as a newline-terminated JSON object on stdout).
|
||||
//
|
||||
// @param {string} buffered — accumulated stdout string (may be partial last line)
|
||||
// @returns {{ events: Array<object|{type:'parse_error',raw:string}>, remainder: string }}
|
||||
export function parseStreamJsonLines(buffered) {
|
||||
const lines = buffered.split('\n');
|
||||
const remainder = lines.pop(); // last element is the incomplete trailing line
|
||||
const events = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') continue; // skip blank lines between events
|
||||
try {
|
||||
events.push(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// ADR 0009 Amendment 1: log + skip bad lines, don't abort the stream.
|
||||
console.error('[anthropic] NDJSON parse error on line:', trimmed.slice(0, 120));
|
||||
events.push({ type: 'parse_error', raw: trimmed });
|
||||
}
|
||||
}
|
||||
return { events, remainder: remainder ?? '' };
|
||||
}
|
||||
|
||||
// ── NDJSON event → IR ResponseChunk ──────────────────────────────────────
|
||||
// Maps claude CLI stream-json NDJSON events to IR chunks per ADR 0009 Amendment 1.
|
||||
//
|
||||
// ADR 0009 Amendment 1 § "NDJSON event handling" table:
|
||||
//
|
||||
// system/init → null (consumed; no yield)
|
||||
// stream_event/content_block_delta/text_delta → { type:'delta', content }
|
||||
// assistant → null (aggregate message; already captured by deltas)
|
||||
// result/success → { type:'stop', finish_reason:'stop' }
|
||||
// result/is_error → throws ProviderError
|
||||
// rate_limit_event → null (logged; future audit integration)
|
||||
// control_request → null (log + skip)
|
||||
// parse_error → null (log + skip)
|
||||
// other/unknown → null (log + skip; future-proof)
|
||||
//
|
||||
// Authority: claude CLI v2.1.104 § --output-format stream-json (observed event
|
||||
// shapes on PI231 2026-05-27 live transcript, retained in cc-mem).
|
||||
//
|
||||
// @param {object} event — parsed NDJSON event object
|
||||
// @param {boolean} isFirstDelta — true if no content_block_delta has been yielded yet
|
||||
// @returns {object|null} IR chunk or null (consumed event)
|
||||
export function anthropicStreamJsonEventToIR(event, isFirstDelta = false) {
|
||||
const t = event?.type;
|
||||
|
||||
// system/init — first event always; consumed (no yield)
|
||||
if (t === 'system' && event.subtype === 'init') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// stream_event — contains nested event with content_block_delta
|
||||
if (t === 'stream_event') {
|
||||
const inner = event.event ?? event; // some versions nest under .event
|
||||
if (inner?.type === 'content_block_delta' && inner.delta?.type === 'text_delta') {
|
||||
const chunk = {
|
||||
type: 'delta',
|
||||
content: inner.delta.text ?? '',
|
||||
};
|
||||
if (isFirstDelta) chunk.role = 'assistant';
|
||||
return chunk;
|
||||
}
|
||||
// Other stream_event sub-types (content_block_start, message_delta, etc.)
|
||||
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".
|
||||
if (t === 'assistant') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// result — terminal event
|
||||
if (t === 'result') {
|
||||
if (event.is_error === true) {
|
||||
throw new ProviderError(
|
||||
event.error_message ?? event.result ?? 'claude returned is_error',
|
||||
'PROVIDER_ERROR',
|
||||
);
|
||||
}
|
||||
// success or other subtype — treat as stop
|
||||
return { type: 'stop', finish_reason: 'stop' };
|
||||
}
|
||||
|
||||
// rate_limit_event — future: forward to audit/dashboard layer
|
||||
if (t === 'rate_limit_event') {
|
||||
// ADR 0009 Amendment 1: rate_limit events logged for future observability work.
|
||||
// Not yielded as IR chunks (no current consumer).
|
||||
return null;
|
||||
}
|
||||
|
||||
// control_request — per Anthropic stream-json docs
|
||||
if (t === 'control_request') {
|
||||
console.error('[anthropic] stream_json control_request event (ignored):', JSON.stringify(event).slice(0, 120));
|
||||
return null;
|
||||
}
|
||||
|
||||
// parse_error — generated by parseStreamJsonLines on bad lines
|
||||
if (t === 'parse_error') {
|
||||
return null; // already logged by parseStreamJsonLines
|
||||
}
|
||||
|
||||
// Unknown event type — log + skip; future-proof for new claude CLI events
|
||||
if (t !== undefined) {
|
||||
console.error('[anthropic] unknown stream_json event type:', t);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── CLI args builder ──────────────────────────────────────────────────────
|
||||
// Ported from OCP server.mjs:384-414 (buildCliArgs), stripped of session
|
||||
// management (OLP is stateless per AGENTS.md § "No conversation state").
|
||||
// ADR 0009 Amendment 1 — replaces the former `-p --output-format text` spawn
|
||||
// with `--output-format stream-json --verbose --no-session-persistence
|
||||
// --system-prompt <text>`.
|
||||
//
|
||||
// OCP server.mjs:385: const args = ["-p", "--model", cliModel, "--output-format", "text"];
|
||||
// OCP server.mjs:392: args.push("--no-session-persistence"); // when no sessionInfo
|
||||
// OCP server.mjs:395-400: --dangerously-skip-permissions or --allowedTools
|
||||
// Authority (all verified live on PI231, claude CLI v2.1.104, 2026-05-27):
|
||||
// - claude CLI v2.1.104 § --output-format stream-json — NDJSON event stream
|
||||
// - claude CLI v2.1.104 § --verbose — required for per-token streaming events
|
||||
// (without --verbose, only the final result event is emitted)
|
||||
// - claude CLI v2.1.104 § --no-session-persistence — stateless per-spawn
|
||||
// - claude CLI v2.1.104 § --system-prompt — full default-prompt replacement
|
||||
// (suppresses cwd / OS / tool descriptions from the claude CLI default prompt)
|
||||
//
|
||||
// OLP variant: always --no-session-persistence (stateless). No permissions flags at D4.
|
||||
function buildCliArgs(model) {
|
||||
// NOTE: No `-p` flag. ADR 0009 Amendment 1 § "Locked decision" — Transport A
|
||||
// (stream-json without -p) confirmed working on v2.1.104. If the `result` event
|
||||
// is not emitted (e.g. version drift), proc.on('close') emits a synthetic stop.
|
||||
//
|
||||
// @param {string} model — model ID to pass via --model
|
||||
// @param {string} systemPrompt — full system prompt text (from extractSystemPrompt)
|
||||
// @returns {string[]} CLI argument array
|
||||
export function buildCliArgs(model, systemPrompt) {
|
||||
return [
|
||||
'-p',
|
||||
'--model', model,
|
||||
'--output-format', 'text',
|
||||
'--output-format', 'stream-json',
|
||||
'--verbose',
|
||||
'--no-session-persistence',
|
||||
'--system-prompt', systemPrompt,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -645,8 +821,12 @@ function buildSpawnEnv() {
|
||||
// Returns an AsyncIterator<IRResponseChunk> per ADR 0002 § Provider contract.
|
||||
// `spawnImpl` is swappable for tests (dependency injection, no real binary needed).
|
||||
//
|
||||
// OCP server.mjs:542: const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
// OCP server.mjs:586-587: proc.stdin.write(prompt); proc.stdin.end();
|
||||
// ADR 0009 Amendment 1: uses NDJSON stream-json output path (no -p).
|
||||
// stdout is line-buffered via parseStreamJsonLines; each parsed event is
|
||||
// dispatched through anthropicStreamJsonEventToIR. The `result` event emits the
|
||||
// stop chunk; proc.on('close') is the safety net if `result` is never emitted.
|
||||
//
|
||||
// OCP server.mjs:542: const proc = spawn(CLAUDE, cliArgs, { env, stdio: [...] });
|
||||
async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
const auth = authContext ?? readAuthArtifact();
|
||||
if (!auth?.accessToken) {
|
||||
@@ -657,15 +837,18 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
}
|
||||
|
||||
const bin = resolveClaudeBin();
|
||||
const args = buildCliArgs(irRequest.model);
|
||||
const env = buildSpawnEnv();
|
||||
|
||||
// Inject OAuth token so the CLI can authenticate.
|
||||
// OCP server.mjs:864-888: the token is read from keychain/.credentials.json/env
|
||||
// and passed as CLAUDE_CODE_OAUTH_TOKEN for the spawn invocation.
|
||||
// We set it explicitly here so the plugin is self-contained.
|
||||
env.CLAUDE_CODE_OAUTH_TOKEN = auth.accessToken;
|
||||
|
||||
// ADR 0009 Amendment 1: system prompt extracted from IR messages,
|
||||
// prepended with OLP_SYSTEM_PROMPT_WRAPPER, passed via --system-prompt.
|
||||
const systemPrompt = extractSystemPrompt(irRequest);
|
||||
const args = buildCliArgs(irRequest.model, systemPrompt);
|
||||
|
||||
// stdin: serialized user/assistant/tool messages (system skipped — goes via --system-prompt)
|
||||
const prompt = irToAnthropic(irRequest);
|
||||
|
||||
// OCP server.mjs:542: spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] })
|
||||
@@ -678,6 +861,11 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
// Yield IR chunks as they arrive from stdout.
|
||||
let isFirstChunk = true;
|
||||
let accumulatedStderr = '';
|
||||
// NDJSON line buffer — accumulates partial lines across data events
|
||||
let lineBuffer = '';
|
||||
// Track whether a result event was seen — prevents double-stop emission
|
||||
// (result event yields stop; if close arrives after the break, no synthetic stop needed)
|
||||
let resultEventSeen = false;
|
||||
|
||||
// Convert proc events to an async generator using a push-buffer + signal approach.
|
||||
const chunks = [];
|
||||
@@ -698,9 +886,14 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0009 Amendment 1: NDJSON line-buffered stdout parsing
|
||||
proc.stdout.on('data', (d) => {
|
||||
const text = d.toString();
|
||||
push({ type: 'data', text });
|
||||
lineBuffer += d.toString();
|
||||
const { events, remainder } = parseStreamJsonLines(lineBuffer);
|
||||
lineBuffer = remainder;
|
||||
for (const event of events) {
|
||||
push({ type: 'event', event });
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (d) => {
|
||||
@@ -769,9 +962,24 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.type === 'data') {
|
||||
yield anthropicChunkToIR(item.text, isFirstChunk);
|
||||
isFirstChunk = false;
|
||||
// ADR 0009 Amendment 1: NDJSON events dispatched through IR mapper
|
||||
if (item.type === 'event') {
|
||||
let irChunk;
|
||||
try {
|
||||
irChunk = anthropicStreamJsonEventToIR(item.event, isFirstChunk);
|
||||
} catch (e) {
|
||||
// anthropicStreamJsonEventToIR throws ProviderError on result.is_error
|
||||
throw e;
|
||||
}
|
||||
if (irChunk !== null) {
|
||||
yield irChunk;
|
||||
if (irChunk.type === 'delta') isFirstChunk = false;
|
||||
if (irChunk.type === 'stop') {
|
||||
resultEventSeen = true;
|
||||
break; // result event = terminal; don't emit synthetic stop
|
||||
}
|
||||
}
|
||||
// null = consumed event (system/init, rate_limit_event, etc.) — continue
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -794,15 +1002,20 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
);
|
||||
}
|
||||
|
||||
// Process close
|
||||
// Process close — safety net if no `result` event was emitted
|
||||
// (e.g. version drift where stream-json is not supported without -p).
|
||||
// Only throw on non-zero exit; zero exit with no result → emit synthetic stop.
|
||||
if (exitCode !== 0 && !spawnTimedOut) {
|
||||
const errMsg = accumulatedStderr.slice(0, 300) || `claude exit ${exitCode}`;
|
||||
// Map known exit patterns to IR error chunk, then throw
|
||||
throw new ProviderError(errMsg, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
// Normal exit: emit stop chunk
|
||||
if (!spawnTimedOut) {
|
||||
// Normal exit AND no `result` event seen — emit synthetic stop as fallback.
|
||||
// ADR 0009 Amendment 1: if result event was seen, resultEventSeen is true and
|
||||
// the stop was already yielded before the break; don't duplicate it.
|
||||
// This path fires when close happens without a prior result event (e.g. very old
|
||||
// claude versions, partial output, or version drift where stream-json changes).
|
||||
if (!spawnTimedOut && !resultEventSeen) {
|
||||
yield anthropicStopToIR('stop');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user