Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions src/adapters/claude-cli/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
18 changes: 14 additions & 4 deletions src/adapters/coding-agent/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -368,11 +370,18 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
});

let terminalEmitted = false;
const emitOnce = (event: AdapterEvent): void => {
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);
};

Expand Down Expand Up @@ -648,7 +657,8 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
}
emitOnce(event.type === "error"
? { ...event, message: redactSecrets(event.message, profile.tokenEnv, apiKey) }
: event);
: event, event.type === "error" && message.type === "result"
? redactSecrets(event.message, profile.tokenEnv, apiKey) : undefined);
}
if (failClosed) break;
const sawToolTurnStop = sawSelectedToolTurnStop();
Expand Down Expand Up @@ -762,7 +772,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
};
emitOnce(input.classifyFailure
? input.classifyFailure({ source: "process-exit", exitCode: child.exitCode, stderr, error, profile })
: error);
: error, stderr || undefined);
} else if (toolBridge && deferredResultDone !== undefined && !sawSelectedToolTurnStop()) {
emitOnce({
type: "error",
Expand All @@ -783,7 +793,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
errorType: "upstream_error",
code: "protocol_error",
retryable: false,
});
}, stderr || undefined);
}
}
} finally {
Expand Down
2 changes: 2 additions & 0 deletions structure/providers/claude-cli-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Claude Code controls actual cache breakpoints, so stable replay alone is not a m

## Usage admission

The shared runner's `onCliOutputError` callback carries accepted CLI error-result text or captured stderr on unsuccessful completion, after redaction. Claude records usage refusals only from that callback; local setup, spawn, stream, replay, and bridge-validation failures retain their client error semantics without recording a subscription reset. Login guidance is applied separately to the emitted client event.

`src/adapters/claude-cli/usage-admission.ts` gates every `claude-cli` launch on the local Claude Code subscription quota. The adapter checks it before spawning the CLI, and the Responses executor checks it before dispatch so an exhausted plan returns 429 `claude_subscription_cooldown` with `Retry-After` instead of a generic 502; Chat Completions keeps that code. Admission and cache invalidation share one window selection (plan windows plus custom windows in scope for the requested model family) and one `>= 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 `<CLAUDE_CONFIG_DIR>/.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 `<OPENCODEX_HOME>/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.
93 changes: 93 additions & 0 deletions tests/providers/claude-cli-usage-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<void>[] = [];
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<void>[] = [];
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<void>[] = [];
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, {
Expand Down
32 changes: 32 additions & 0 deletions tests/providers/codebuddy-tool-bridge-turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
});
});
Loading