diff --git a/src/adapters/claude-cli/adapter.ts b/src/adapters/claude-cli/adapter.ts index 3ce81629dc..b8e60ba069 100644 --- a/src/adapters/claude-cli/adapter.ts +++ b/src/adapters/claude-cli/adapter.ts @@ -286,12 +286,10 @@ export function createClaudeCliAdapter(provider: OcxProviderConfig, deps: Claude provider, parsed, incoming, - emit: withClaudeLoginHint(event => { - if (event.type === "error") { - void Promise.resolve((deps.usageRefusal ?? recordClaudeUsageRefusal)(event.message)).catch(() => {}); - } - emit(event); - }), + emit: withClaudeLoginHint(emit), + onCliOutputError: message => { + void Promise.resolve().then(() => (deps.usageRefusal ?? recordClaudeUsageRefusal)(message)).catch(() => {}); + }, ...(toolBridge ? { toolBridge } : {}), conversationInput: buildStableClaudeConversationInput, buildArgs: (profile, req, prov) => buildArgs(profile as ClaudeCliProfile, req, prov, promptFile), diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index 11b14f439b..5b5faf966e 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -121,6 +121,8 @@ export interface CodingAgentTurnInput { parsed: OcxParsedRequest; incoming: IncomingMeta; emit: (event: AdapterEvent) => void; + /** Observes accepted CLI result/stderr failures, before client-side error decoration. */ + onCliOutputError?: (message: string) => void; /** Family-specific headless argument builder (tools disabled, model, reasoning, system prompt). */ buildArgs: (profile: CodingAgentProviderProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig) => string[]; /** Family-specific scoped env builder (credential + region switch on top of baseScopedEnv). */ @@ -368,11 +370,18 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { + const emitOnce = (event: AdapterEvent, cliOutputError?: string): void => { if (event.type === "done" || event.type === "error" || event.type === "incomplete") { if (terminalEmitted) return; terminalEmitted = true; } + if (event.type === "error" && cliOutputError !== undefined) { + try { + input.onCliOutputError?.(cliOutputError); + } catch { + // An observer failure must never drop the client's terminal event or turn it into a rejection. + } + } emit(event); }; @@ -648,7 +657,8 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise= 100` threshold, so an expired window of another family never forces a re-read. A limit the CLI itself reports with a parseable reset ("resets 3pm (Zone)") is remembered until that time. A probe re-reads the account before it commits: an account switch mid-probe discards the answer (`unknown`, nothing persisted), and the newest refusal is merged at commit time with no await between that read and the write, so a CLI-reported reset is never overwritten. The file has one writer per `OPENCODEX_HOME`, the same single-owner topology as the spend ledger. The check reads the CLI's own sign-in in memory (`detectClaudeCodeTokenAsync`: Keychain without blocking the event loop, or `/.credentials.json`), caches the resolved identity for one minute, and sends the access token only to Anthropic's usage endpoint through `src/providers/claude-cli-usage.ts`, the same reader the account-pool quota probe uses. `CLAUDE_CONFIG_DIR` is passed to the child so the CLI and the check read one profile. The cache file `/claude-usage-admission.json` stores only percentages, reset times and a hashed identity key. Every failure (no sign-in, malformed credential, unreachable endpoint) fails open as `unknown`, and a test process (`OCX_TEST_HOME_GUARD`) never resolves the real sign-in. If the executor's check throws or refuses, it releases the web-search sidecar's probe lease first; `tests/responses/responses-claude-preflight-lease.test.ts` and `tests/providers/claude-cli-usage-admission.test.ts` cover both. diff --git a/tests/providers/claude-cli-usage-admission.test.ts b/tests/providers/claude-cli-usage-admission.test.ts index 3424961bfe..ef4eb826f8 100644 --- a/tests/providers/claude-cli-usage-admission.test.ts +++ b/tests/providers/claude-cli-usage-admission.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ChildProcess } from "node:child_process"; +import { Readable, Writable } from "node:stream"; import { buildChildEnv, createClaudeCliAdapter } from "../../src/adapters/claude-cli/adapter"; import { CLAUDE_CLI_PROFILE } from "../../src/adapters/claude-cli/profiles"; import { @@ -158,6 +159,98 @@ describe("claude-cli adapter admission", () => { const provider = { adapter: "claude-cli", baseUrl: CLAUDE_CLI_PROFILE.canonicalBaseUrl } as OcxProviderConfig; const parsed = { modelId: "claude-sonnet-5-5", stream: true, options: {}, context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] } } as OcxParsedRequest; + const limit = "You've hit your limit ยท resets 3pm (UTC)"; + const now = Date.UTC(2026, 9, 8, 12, 0); + const child = (lines: unknown[], stderr = "", exitCode = 0, failure?: "spawn" | "stream" | "stdin") => { + const proc = new EventEmitter() as unknown as ChildProcess; + proc.stdout = failure === "stream" ? Readable.from((async function* () { throw new Error(limit); })()) + : Readable.from(lines.map(line => new TextEncoder().encode(JSON.stringify(line) + "\n"))); + proc.stderr = Readable.from(stderr ? [stderr] : []); + proc.stdin = new Writable({ write(_chunk, _encoding, done) { done(); } }); + if (failure === "stdin") proc.stdin.write = () => { throw new Error(limit); }; + proc.kill = () => true; + proc.exitCode = null; + setTimeout(() => { + if (failure === "spawn") proc.emit("error", new Error(limit)); + proc.exitCode = exitCode; + proc.emit("close", exitCode); + }, 3); + return proc; + }; + + for (const failure of ["spawn", "stream", "stdin"] as const) { + test(`local ${failure} errors do not persist a CLI refusal`, async () => { + const statePath = tempState(); + const writes: Promise[] = []; + const adapter = createClaudeCliAdapter(provider, { + usageAdmission: async () => ({ state: "available", checkedAt: now }), + usageRefusal: message => { writes.push(recordClaudeUsageRefusal(message, { now: () => now, identity, statePath })); }, + which: () => process.execPath, + spawn: () => child([], "", 0, failure), + killGraceMs: 10, + reapTimeoutMs: 50, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed, + { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + await Promise.all(writes); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", message: expect.stringContaining("hit your limit") }); + expect(existsSync(statePath)).toBe(false); + expect(writes).toHaveLength(0); + }); + } + + for (const source of ["result", "exit-stderr", "eof-stderr"] as const) { + test(`a CLI ${source} refusal still persists its reset`, async () => { + const statePath = tempState(); + const writes: Promise[] = []; + const adapter = createClaudeCliAdapter(provider, { + usageAdmission: async () => ({ state: "available", checkedAt: now }), + usageRefusal: message => { writes.push(recordClaudeUsageRefusal(message, { now: () => now, identity, statePath })); }, + which: () => process.execPath, + spawn: () => child(source === "result" ? [{ type: "result", is_error: true, result: limit }] : [], + source === "result" ? "" : limit, source === "exit-stderr" ? 1 : 0), + killGraceMs: 10, + reapTimeoutMs: 50, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + await Promise.all(writes); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", message: expect.stringContaining(limit) }); + expect(JSON.parse(readFileSync(statePath, "utf8")).refusalUntil).toBe(Date.UTC(2026, 9, 8, 15, 0)); + expect((await checkClaudeUsageAdmission(parsed.modelId, { now: () => now, identity, probe: async () => freshQuota, statePath })).state).toBe("exhausted"); + }); + } + + test("successful output and a bridge rejection do not promote stderr into a refusal", async () => { + for (const bridge of [false, true]) { + const statePath = tempState(); + const writes: Promise[] = []; + const adapter = createClaudeCliAdapter(provider, { + usageAdmission: async () => ({ state: "available", checkedAt: now }), + usageRefusal: message => { writes.push(recordClaudeUsageRefusal(message, { now: () => now, identity, statePath })); }, + which: () => process.execPath, + spawn: () => child(bridge + ? [{ type: "system", subtype: "init", mcp_servers: [] }, { type: "result", is_error: true, result: limit }] + : [{ type: "result", result: limit }], limit), + killGraceMs: 10, + reapTimeoutMs: 50, + }); + const events: AdapterEvent[] = []; + const request: OcxParsedRequest = bridge ? { ...parsed, context: { ...parsed.context, + tools: [{ name: "read", description: "Read", parameters: { type: "object" } }], + } } : parsed; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + await Promise.all(writes); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject(bridge ? { type: "error", code: "tool_bridge_init_mismatch" } : { type: "done" }); + expect(writes).toHaveLength(0); + expect(existsSync(statePath)).toBe(false); + } + }); + test("an exhausted subscription is refused with a 429 cooldown before any spawn", async () => { let spawned = 0; const adapter = createClaudeCliAdapter(provider, { diff --git a/tests/providers/codebuddy-tool-bridge-turn.test.ts b/tests/providers/codebuddy-tool-bridge-turn.test.ts index 8d3b7ed050..09e5059b31 100644 --- a/tests/providers/codebuddy-tool-bridge-turn.test.ts +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -9,6 +9,7 @@ import type { ChildProcess } from "node:child_process"; import { createCodeBuddyAdapter, type SpawnFn } from "../../src/adapters/codebuddy/adapter"; import { buildCodeBuddyToolBridge } from "../../src/adapters/codebuddy/tool-bridge"; import { CODEBUDDY_GLOBAL_PROFILE, clearCodeBuddyBinaryCache } from "../../src/adapters/codebuddy/profiles"; +import { runCodingAgentTurn } from "../../src/adapters/coding-agent/turn"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../src/types"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; @@ -971,4 +972,35 @@ describe("CodeBuddy capture-only tool bridge turn", () => { expect(charges).toBe(2); expect(translatorBudget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); }); + + test("a cli-output-error observer that throws cannot drop the client's terminal error", async () => { + const stderrText = "stderr refusal text on process exit"; + const child = new EventEmitter() as FakeChild; + child.stdout = Readable.from([]); + child.stderr = Readable.from([enc.encode(stderrText)]); + child.stdin = new Writable({ write(_chunk, _enc, cb) { cb(); } }); + child.killed = false; + child.exitCode = null; + child.kill = () => { child.killed = true; return true; }; + setTimeout(() => { child.exitCode = 1; child.emit("close", 1); }, 3); + + const events: AdapterEvent[] = []; + const observed: string[] = []; + await runCodingAgentTurn({ + profiles: [CODEBUDDY_GLOBAL_PROFILE], + provider: provider(), + parsed: parsed(), + incoming: incoming(), + emit: e => events.push(e), + onCliOutputError: message => { observed.push(message); throw new Error("observer failure"); }, + buildArgs: () => [], + buildEnv: () => ({}), + deps: { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 10, reapTimeoutMs: 50 }, + }); + expect(observed).toHaveLength(1); + expect(observed[0]).toContain(stderrText); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: "process_exit_error", retryable: false }); + expect(events[0].type === "error" ? events[0].message : "").toContain(stderrText); + }); });