Files
openclaw/extensions/codex/src/app-server/test-support.ts
Peter Steinberger 554d772c1a refactor(codex): keyed turn routing, client-scoped rate limits, and resume subscription safety (#101376)
* feat(codex): scope app-server rate limits to the physical client

Replace the process-global rate-limit cache with a WeakMap keyed by the
physical app-server client, tracking per-limitId revisions. Rolling
account/rateLimits/updated notifications merge sparsely per the protocol
contract (credits/individualLimit/planType survive nulls), and
usage-limit errors only trust a snapshot for auth-profile blocking when
the same client observed a primary update during the failing turn's
startup. Fixes cross-client rate-limit bleed in usage-limit error
messages. A new client-runtime module installs the
account/chatgptAuthTokens/refresh handler and the rate-limit observer
once per physical client, replacing per-start inline handlers in
shared-client, run-attempt, and side-question.

* refactor(codex): split thread/resume subscription safety into thread-resume

Move the thread/resume request out of thread-lifecycle into a dedicated
thread-resume module that retires the exact physical client when resume
acceptance is indeterminate: only a structured RPC rejection proves
Codex holds no subscription, so any other failure abandons the client
instead of returning it to the shared pool. Resume responses naming a
different thread now fail closed (assertCodexThreadResumeSubscription),
and the fresh-thread fallback requires a released subscription unless
the resume was a proven RPC rejection.

* refactor(codex): replace client-factory positional DI with shared-client factory

Delete the lazy positional-argument CodexAppServerClientFactory and use
the options-object factory type exported from shared-client. Callers in
run-attempt, compact, bounded-turn, provider-capabilities, and the
web-search provider now default to getLeasedSharedCodexAppServerClient
directly; the lazy indirection was ineffective because those modules
already import shared-client statically.

* feat(codex): route app-server turn traffic through a keyed turn router

Install one turn router per physical app-server client and replace the
broad per-attempt notification/request fanout with explicit per-thread
routes. Attempt startup reserves the thread route (before thread/resume
on the resume path, so early notifications buffer instead of racing),
run-attempt activates it with receive-time, queued, and request
handlers, arms the route before turn/start, binds the accepted turn id
to flush buffered traffic in wire order, and releases the route on
cleanup. Requests for a pending turn wait for binding instead of being
auto-declined, native turn completion waits use route state instead of
scanning buffered notifications, and correlation readers now match the
canonical v2 wire shapes only (top-level threadId, nested turn.id). The
unscoped response-delta lease-count attribution and its client API are
deleted along with the retired correlation predicates.

* test(codex): reset the shared binding store between thread-lifecycle tests

SQLite bindings are keyed by session identity rather than the per-test
temp dir, so earlier tests leaked resumable threads into fresh-start
expectations. The old silent resume-failure fallback masked the leak;
subscription safety surfaces it.

* test(codex): reset the binding store between delivery-hint iterations

The loop reuses one session identity across iterations, so the previous
iteration's thread would resume against a harness that cannot serve it.
2026-07-07 07:25:15 +01:00

107 lines
3.2 KiB
TypeScript

/**
* Shared Codex app-server test helpers for model fixtures and in-memory client
* transports.
*/
import { EventEmitter } from "node:events";
import { PassThrough, Writable } from "node:stream";
import type { Model } from "openclaw/plugin-sdk/llm";
import { vi } from "vitest";
import { CodexAppServerClient } from "./client.js";
import type { CodexAppServerClientFactory, CodexAppServerClientOptions } from "./shared-client.js";
/** Positional naked-client injection contract confined to tests. */
export type CodexTestAppServerClientFactory = (
startOptions?: CodexAppServerClientOptions["startOptions"],
authProfileId?: string,
agentDir?: string,
config?: CodexAppServerClientOptions["config"],
options?: CodexAppServerClientOptions,
) => Promise<CodexAppServerClient>;
/** Adapts a positional test factory to the production options-object contract. */
export function adaptCodexTestClientFactory(
factory: CodexTestAppServerClientFactory,
): CodexAppServerClientFactory {
return (options) =>
factory(
options?.startOptions,
options?.authProfileId ?? undefined,
options?.agentDir,
options?.config,
options,
);
}
/** Builds a representative Codex-capable model fixture for app-server tests. */
export function createCodexTestModel(provider = "openai", input = ["text"]): Model {
return {
id: "gpt-5.4-codex",
name: "gpt-5.4-codex",
provider,
api: "openai-chatgpt-responses",
input,
reasoning: true,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_000,
} as Model;
}
/** Creates an in-memory Codex app-server client harness with writable stdout frames. */
export function createClientHarness() {
const stdout = new PassThrough();
const writes: string[] = [];
let stdinDestroyed = false;
let exitEmitted = false;
let emitProcessExit: () => void = () => undefined;
type HarnessProcess = EventEmitter & {
stdin: Writable;
stdout: PassThrough;
stderr: PassThrough;
killed: boolean;
kill: (signal?: NodeJS.Signals) => unknown;
};
const stdin = new Writable({
write(chunk, _encoding, callback) {
writes.push(chunk.toString());
callback();
},
});
const destroyStdin = stdin.destroy.bind(stdin);
stdin.destroy = ((error?: Error) => {
stdinDestroyed = true;
const result = destroyStdin(error);
if (!exitEmitted) {
exitEmitted = true;
// Let stdin surface pipe errors before the harness emits the fake child exit.
// Otherwise close-reason tests can race EPIPE against a synthetic clean exit.
setImmediate(emitProcessExit);
}
return result;
}) as typeof stdin.destroy;
const process: HarnessProcess = Object.assign(new EventEmitter(), {
stdin,
stdout,
stderr: new PassThrough(),
killed: false,
kill: vi.fn((_signal?: NodeJS.Signals) => {
process.killed = true;
}),
});
emitProcessExit = () => {
process.emit("exit", 0, null);
};
const client = CodexAppServerClient.fromTransportForTests(process);
return {
client,
process,
writes,
get stdinDestroyed() {
return stdinDestroyed;
},
send(message: unknown) {
stdout.write(`${JSON.stringify(message)}\n`);
},
};
}