From 97e7d16585302fed5c3d812b30ec0df2200f0ae1 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Wed, 27 May 2026 22:28:10 +1000 Subject: [PATCH] feat(anthropic): stream-json + --system-prompt (ADR 0009 Amendment 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 4 + .../0009-interactive-mode-path-placeholder.md | 120 ++++- lib/providers/anthropic.mjs | 359 ++++++++++++--- test-features.mjs | 415 ++++++++++++++++-- 4 files changed, 794 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92964f4..ad34318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this ## Unreleased +### Phase 6 D-day — stream-json transport for Anthropic provider (ADR 0009 Amendment 1) + +- feat(anthropic): stream-json output + --system-prompt suppression of env-block / tool descriptions (ADR 0009 Amendment 1). Cuts ~64% per-request cost on Sonnet 4.6 via 30% input-token reduction ($0.0216 → $0.0078), fixes bot self-check hallucination (model no longer claims server cwd / OS / tool names), exposes rate_limit + usage events from NDJSON for future audit/dashboard work. Per-key API + cache + audit semantics unchanged. claude CLI v2.1.104 verified; warn if claude-version outside v2.1.100–v2.1.149. + ### F4 — `bin/olp.mjs` + `olp-plugin/index.js` migration to `quota_v2` shape **Codex post-v0.5.0 review Q4.** Both CLI surfaces (`olp usage` and `/olp usage`) previously fell through to "no quota api" for every provider because they read the legacy `body.quota` shape, which never carries `percent_used` or meaningful `available` data. Now that the server (v0.5.0+) emits `body.quota_v2` per ADR 0008 Amendment 2, both surfaces prefer `quota_v2` and fall back to legacy `quota` on older servers. diff --git a/docs/adr/0009-interactive-mode-path-placeholder.md b/docs/adr/0009-interactive-mode-path-placeholder.md index 90d794a..4a9d44b 100644 --- a/docs/adr/0009-interactive-mode-path-placeholder.md +++ b/docs/adr/0009-interactive-mode-path-placeholder.md @@ -1,7 +1,7 @@ # ADR 0009 — Anthropic Interactive-Mode Path (Placeholder) -- **Date:** 2026-05-25 -- **Status:** Draft (Placeholder — blocked on OCP ADR 0007 P0 experiment outcome; no implementation D-day scheduled until P0 lands) +- **Date:** 2026-05-25 (Placeholder); 2026-05-27 Amendment 1 (Accepted) +- **Status:** **Accepted** (post-Amendment 1 — OLP self-spike supersedes OCP-wait; implementation D-day scheduled this Phase 6) - **Authors:** project maintainer (with AI advisory drafting) - **Related:** - **OCP ADR 0007** (Interactive-Mode Execution Pool, stream-json) — at `~/ocp/docs/adr/0007-interactive-mode-pool.md` on the maintainer's workstation. Pin reference at the time of this writing: OCP ADR 0007 is Draft status pending the same P0 outcome. @@ -193,5 +193,117 @@ If OCP P0 fails, **this ADR is shelved** and Phase 4 ordering is unchanged. ## Status transitions (recorded for clarity) - 2026-05-25 — Created as Draft (Placeholder). OCP ADR 0007 also Draft. -- _(future)_ — If OCP ADR 0007 → Accepted with a confirmed transport: this ADR moves to "Pending Phase 4 implementation D-day", maintainer decides Option 1 / 2 / 3 + lane. -- _(future)_ — If OCP ADR 0007 → Rejected: this ADR moves to "Shelved (upstream P0 failure)" with a note explaining the fallback (multi-provider routing already covers). +- 2026-05-27 — Amendment 1 promotes to **Accepted**. OLP self-spike + empirical Transport-A confirmation on `claude` CLI v2.1.104 superseded wait-for-OCP. OCP is now in maintenance mode (per maintainer statement 2026-05-27 session) — OLP leads. Implementation lane: **Option 1 (parallel implementation, no warm pool, no PTY)**, scope reduced from "10-day warm pool with billing router" to "2-3-day stateless stream-json adapter". + +--- + +## Amendment 1 — 2026-05-27: Self-spike supersedes wait-for-OCP; lock Option 1 with stream-json-no-`-p` transport + +### Trigger + +Two findings on 2026-05-27 changed the placeholder's premises: + +1. **OCP is no longer the lead project.** The maintainer stated in the 2026-05-27 session: "OCP 不会大改动…主力方向放到 OLP." The "wait-and-port" strategy implicitly assumed OCP would do the P0 first. With OCP in maintenance mode, OLP cannot wait — the 2026-06-15 Anthropic billing split is 19 days out from this amendment. + +2. **OLP self-spike confirmed Transport A (`--output-format stream-json --verbose` without `-p`) emits NDJSON on Claude Code v2.1.104.** The placeholder ADR § 1.3 cited OCP's "v2.1.150 only" observation as the binding caveat. Local empirical re-test on 2026-05-27 against PI231's deployed `claude` v2.1.104 produced the full NDJSON event stream (system/init + stream_event token deltas + message_stop + result + rate_limit_event) for invocations **without** `-p`. The `claude --help` text saying "(only works with --print)" is misleading — the flags accept invocation without `-p` and produce the documented NDJSON shape. + +### Additional spike findings (2026-05-27 billing classification) + +A separate web/GitHub research spike on the 2026-06-15 billing classification returned: + +- Anthropic's published policy is **intent-based, not mechanism-based**. The Agent SDK credit pool covers: Agent SDK Python/TypeScript packages, `claude -p`, GitHub Actions, **and "third-party apps that authenticate with your Claude subscription through the Agent SDK"**. Subscription pool covers "Claude Code in the terminal or your IDE in interactive mode." +- The third-party-app clause is the load-bearing ambiguity. OLP qualifies as a third-party app regardless of which CLI mode it spawns. If Anthropic tightens that clause from "via Agent SDK" to "any third-party app," OLP is caught regardless of `-p` flag presence. +- Behavioral fingerprinting (request cadence, OAuth-scope patterns, isTTY absence) is a separate detection vector Anthropic could deploy without policy-text changes. + +The spike's recommendation: "viable bridge for ~30-60 days post-2026-06-15, NOT durable solution." + +### Value re-anchoring + +The placeholder framed interactive-mode as "the durable answer to keep OLP anthropic subscription value past 2026-06-15." The 2026-05-27 spike re-anchors the value: + +| Value | Placeholder framing | 2026-05-27 framing | +|---|---|---| +| Keep subscription pool 6.15+ | **Primary value** | **Uncertain bridge** (30-60 day plausibility) | +| Hallucination fix (env-block / cwd injection) | (Not addressed) | **Primary value** — empirically proven | +| Cost reduction (drop default tool descriptions) | (Not addressed) | **Primary value** — ~30% input token / ~64% per-request cost reduction measured against `--system-prompt` override | +| Observability (rate_limit / cache / usage per request) | (Not addressed) | **Primary value** — NDJSON events expose data the current `--output-format text` path discards | +| Protocol foundation for future tool-call passthrough | (Not addressed) | **Secondary value** — same NDJSON parser is reusable for Phase 8+ tool passthrough work | + +**Net**: even if Anthropic immediately reclassifies third-party apps to Agent SDK pool on 2026-06-15 — making the bridge worthless — the implementation still earns its keep through the other four values. + +### Locked decision + +**Option 1 — Parallel implementation in OLP's `lib/providers/anthropic.mjs`.** + +Lane: stream-json output, no `-p` flag (Transport A confirmed), stateless per-request spawn (no warm pool, no PTY, no node-pty dependency). + +Rejected lanes and why: + +- **Option 2 (chain OCP)** — OCP is in maintenance mode; coupling OLP's anthropic provider to OCP's HTTP shim is the wrong direction. +- **Option 3 (both)** — premature complexity; pick the simple lane first. +- **Warm-process pool** — OLP is stateless per AGENTS.md § "No conversation state". Pool lifecycle, crash backoff, and permission auto-response from OCP ADR 0007 § 4 are unnecessary for OLP's per-request model. +- **PTY (Transport B with node-pty)** — Transport A worked; engines-bump for a native addon is unjustified when the simpler transport produces the documented NDJSON. + +### Implementation scope (Option 1, this Phase 6) + +| Change | Surface | Authority | +|---|---|---| +| `buildCliArgs(model)` drop `-p` and `--output-format text`; add `--output-format stream-json`, `--verbose`, `--no-session-persistence`, `--model` | `lib/providers/anthropic.mjs` | `claude --help` (v2.1.104) § `--output-format` / § `--verbose` | +| `buildCliArgs(model, systemPrompt)` accepts optional system prompt; spawns with `--system-prompt ""` | `lib/providers/anthropic.mjs` | `claude --help` (v2.1.104) § `--system-prompt` | +| OLP-managed system prompt construction (extract client `role:system` IR messages, prepend OLP wrapper saying "you are accessed via HTTP proxy; no local env/fs/shell access; respond directly") | `lib/providers/anthropic.mjs` `irToAnthropic` | This ADR § "OLP system prompt wrapper" below | +| New `anthropicStreamJsonChunkToIR` parser replacing/supplementing `anthropicChunkToIR` — handles NDJSON event types `system/init`, `stream_event/content_block_delta`, `assistant`, `result`, `rate_limit_event` | `lib/providers/anthropic.mjs` | This ADR § "NDJSON event handling" below | +| New tests verifying NDJSON parsing, system-prompt construction, env-block absence | `test-features.mjs` | (test surface; no external authority) | +| README troubleshooting / supported-providers § note about the bridge nature | `README.md` | (docs surface) | + +The `irToAnthropic` text serialization path is preserved for client messages (`role: user`, `role: assistant`); the `role: system` extraction goes to `--system-prompt`. + +### OLP system prompt wrapper + +The wrapper text injected via `--system-prompt`: + +``` +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. +``` + +If the client IR request contains `role: system` messages, their concatenated `content` is appended after a blank line. + +### NDJSON event handling + +The parser must yield IR chunks based on the event stream: + +| NDJSON event | IR yield | Notes | +|---|---|---| +| `{type:"system", subtype:"init"}` | None (consumed for session_id tracking) | First event always; ignore | +| `{type:"stream_event", event:{type:"content_block_delta", delta:{type:"text_delta", text:"..."}}}` | `{type: "delta", content: ""}` | Token-by-token streaming | +| `{type:"assistant"}` | None (already captured by per-token deltas) | Aggregate message; ignore (or use for verify, optional) | +| `{type:"result", subtype:"success"}` | `{type:"stop", finish_reason:"stop"}` | Marks end | +| `{type:"rate_limit_event"}` | None (consumed for audit/dashboard) | Forward to OLP audit/observability layer later (Phase 6+ enhancement) | +| `{type:"control_request"}` | Log + ignore | Per Anthropic stream-json docs | + +The cache key composition (ADR 0005) is unchanged — same IR request hash; the on-the-wire format change is internal to the anthropic plugin. + +### Token cost measurement (binding evidence) + +Two requests against PI231 v2.1.104 on 2026-05-27 with identical user prompt `"reply: OK"`, model `claude-sonnet-4-6`: + +- Default invocation (no `--system-prompt`): `cache_creation_input_tokens=4785`, `cache_read_input_tokens=11816`, total input ≈ 16,601 tokens, `total_cost_usd=$0.0216`. +- With `--system-prompt "You are a chat assistant. Respond directly."`: `cache_creation_input_tokens=1306`, `cache_read_input_tokens=9394`, total input ≈ 10,700 tokens, `total_cost_usd=$0.0078`. + +**Net**: ~30% input token reduction, ~64% per-request cost reduction. Replicable by anyone with `claude` v2.1.104 + OAuth on a similar setup. + +### Caveats binding the implementation + +1. **Bridge value uncertain.** The 30-60 day estimate is a spike judgment, not Anthropic-confirmed. Implementation must continue to function correctly if Anthropic re-routes this path to Agent SDK billing on 2026-06-15 — the only consequence is the bridge value disappears, but the other four values (hallucination / cost / observability / protocol foundation) remain. +2. **No claim about durability.** This ADR amends only as far as "the bridge is worth the 2-3 day investment given the orthogonal values." A future ADR (likely Phase 7 sandbox-runtime + Phase 8 multi-provider robustness) will revisit the anthropic provider's strategic role once the post-2026-06-15 picture clarifies. +3. **Sandbox-runtime still required for real multi-tenant deployment.** Per the 2026-05-27 session prior-art search, Anthropic's official multi-tenant answer is `@anthropic-ai/sandbox-runtime` (OS-level isolation). This ADR does NOT substitute for that work; sandbox-runtime remains Phase 7 scope and is a hard prerequisite before any cloud deployment per `docs/plans/cloud-deployment-family.md`. +4. **CLI version pin guidance.** Stream-json without `-p` was confirmed on v2.1.104. Future versions may tighten this; the plugin's spawn should emit a warning to OLP server log if `claude --version` falls outside a `v2.1.100`–`v2.1.149` range. Hard failure on out-of-range version is NOT required; warning is sufficient for v0.6.x. + +### Updated authority citations (in addition to placeholder § Authority citations) + +- **OLP self-spike — 2026-05-27 session live transcripts** (PI231 ssh; `claude -p --output-format stream-json --verbose` and `claude` no-`-p` variants captured in session log; retained in cc-mem post-implementation). +- **P0 billing classification spike — 2026-05-27** subagent transcript; sources include Anthropic published docs at `code.claude.com/docs/en/headless`, `support.claude.com/en/articles/15036540`, `support.claude.com/en/articles/11145838`. +- **claude CLI v2.1.104 `--help`** (live capture on PI231) § `--output-format`, § `--verbose`, § `--system-prompt`, § `--no-session-persistence`. +- **CLAUDE.md `release_kit.phase_rolling_mode.current_phase`** — Phase 6; this ADR consumes a Phase 6 D-day per the amendment, NOT a Phase 4 D-day (the placeholder's hypothetical scheduling). diff --git a/lib/providers/anthropic.mjs b/lib/providers/anthropic.mjs index 9ae66b4..a35e3f3 100644 --- a/lib/providers/anthropic.mjs +++ b/lib/providers/anthropic.mjs @@ -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 `. + * + * 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] ", assistant → "[Assistant] ", -// 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: } -// 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, 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 `. // -// 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 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'); } } diff --git a/test-features.mjs b/test-features.mjs index eb6ba7a..551eb1d 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -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: '' }; + 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'); + }); +});