fix(codex): full-auto approvals survive relay unavailability, typed failure classification, usage line after fallback (#108626)

* fix(codex): full-auto approvals survive relay unavailability, typed failure classification, usage line after fallback

- full-auto (approvalPolicy never + danger-full-access) exec approvals no
  longer get declined when the native hook relay is unreachable BEFORE
  invocation; an invoked relay's explicit deny, malformed output, or nonzero
  exit still fails closed (#107447)
- startup timeout/abort and request-timeout classification use typed error
  discriminants instead of message prose; EPIPE detection walks error causes;
  compaction keeps the message-based thread-not-found gate because codex-rs
  exposes no dedicated code (its own contract test asserts the message) and
  the generic -32600 would over-match unrelated invalid requests (#99270)
- /status keeps the Codex usage/quota line for sessions whose persisted
  agentHarnessId is codex even when the effective runtime fell back to
  OpenClaw Default; never-codex sessions remain excluded (#105184)

* chore(codex): keep startup error reason type module-local
This commit is contained in:
Peter Steinberger
2026-07-15 22:12:35 -07:00
committed by GitHub
parent 5bfcd779b1
commit 83a4208aed
16 changed files with 318 additions and 36 deletions
@@ -1138,6 +1138,7 @@ describe("Codex app-server approval bridge", () => {
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
autoApprove: true,
nativeHookRelay: {
relayId: "relay-1",
generation: "generation-1",
@@ -1433,6 +1434,7 @@ describe("Codex app-server approval bridge", () => {
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
autoApprove: true,
nativeHookRelay: {
relayId: "relay-1",
generation: "generation-1",
@@ -1564,6 +1566,73 @@ describe("Codex app-server approval bridge", () => {
});
});
it("auto-approves when the expected native hook relay is unavailable in full-auto", async () => {
const params = createParams();
mockInvokeNativeHookRelay.mockRejectedValueOnce(new Error("native hook relay not found"));
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-native-relay-full-auto-missing",
command: "pwd",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
autoApprove: true,
nativeHookRelay: {
relayId: "relay-missing",
generation: "generation-1",
allowedEvents: ["pre_tool_use"],
},
});
expect(result).toEqual({ decision: "acceptForSession" });
expect(mockRunBeforeToolCallHook).toHaveBeenCalledTimes(1);
expect(mockInvokeNativeHookRelay).toHaveBeenCalledTimes(1);
expect(mockCallGatewayTool).not.toHaveBeenCalled();
findApprovalEvent(params, {
status: "approved",
message: "Codex app-server approval auto-approved by runtime policy.",
});
});
it("fails closed when the native hook relay fails after invocation in full-auto", async () => {
const params = createParams();
mockHasNativeHookRelayInvocation.mockReturnValueOnce(false).mockReturnValueOnce(true);
mockInvokeNativeHookRelay.mockRejectedValueOnce(new Error("native hook relay handler failed"));
const result = await handleCodexAppServerApprovalRequest({
method: "item/commandExecution/requestApproval",
requestParams: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "cmd-native-relay-handler-failure",
command: "pwd",
},
paramsForRun: params,
threadId: "thread-1",
turnId: "turn-1",
autoApprove: true,
nativeHookRelay: {
relayId: "relay-1",
generation: "generation-1",
allowedEvents: ["pre_tool_use"],
},
});
expect(result).toEqual({ decision: "decline" });
expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled();
expect(mockCallGatewayTool).not.toHaveBeenCalled();
findApprovalEvent(params, {
status: "denied",
message:
"OpenClaw native hook relay unavailable for Codex app-server approval: native hook relay handler failed",
});
});
it("keeps non-command approvals on the app-server approval route when a native relay is registered", async () => {
const params = createParams();
mockCallGatewayTool
@@ -103,6 +103,7 @@ export async function handleCodexAppServerApprovalRequest(params: {
paramsForRun: params.paramsForRun,
context,
nativeHookRelay: params.nativeHookRelay,
autoApprove: params.autoApprove,
signal: params.signal,
});
if (policyOutcome?.outcome === "denied") {
@@ -406,6 +407,7 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
NativeHookRelayRegistrationHandle,
"allowedEvents" | "generation" | "relayId"
>;
autoApprove?: boolean;
signal?: AbortSignal;
}): Promise<ApprovalPolicyOutcome | undefined> {
const policyRequest = buildOpenClawToolPolicyRequest(params.method, params.requestParams);
@@ -419,6 +421,7 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
context: params.context,
policyRequest,
nativeHookRelay: params.nativeHookRelay,
autoApprove: params.autoApprove,
cwd,
signal: params.signal,
});
@@ -499,6 +502,7 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: {
NativeHookRelayRegistrationHandle,
"allowedEvents" | "generation" | "relayId"
>;
autoApprove?: boolean;
cwd?: string;
signal?: AbortSignal;
}): Promise<
@@ -597,6 +601,18 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: {
}
return { handled: true };
} catch (error) {
// Only a relay that failed before invocation is unavailable. Once invoked,
// handler failures join explicit denials and malformed replies in failing closed.
if (
params.autoApprove === true &&
!hasNativeHookRelayInvocation({
relayId: params.nativeHookRelay.relayId,
event: "pre_tool_use",
toolUseId: params.context.approvalId,
})
) {
return undefined;
}
return {
handled: true,
blocked: true,
@@ -9,7 +9,8 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startCodexAttemptThread } from "./attempt-startup.js";
import { CodexAppServerClient } from "./client.js";
import { isCodexAppServerStartupError } from "./attempt-timeouts.js";
import { CodexAppServerClient, isCodexAppServerRequestTimeoutError } from "./client.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAME,
type CodexPluginConfig,
@@ -479,6 +480,7 @@ describe("startCodexAttemptThread", () => {
timeout: 1_000,
});
expect(error).toBeInstanceOf(Error);
expect(isCodexAppServerStartupError(error, "timed_out")).toBe(true);
expect((error as Error).message).toBe("codex app-server startup timed out");
expect(harness.stdinDestroyed).toBe(true);
});
@@ -529,6 +531,7 @@ describe("startCodexAttemptThread", () => {
const error = await runError;
expect(error).toBeInstanceOf(Error);
expect(isCodexAppServerStartupError(error, "timed_out")).toBe(true);
expect((error as Error).message).toBe("codex app-server initialize timed out");
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
@@ -738,6 +741,7 @@ describe("startCodexAttemptThread", () => {
const error = await runError;
expect(error).toBeInstanceOf(Error);
expect(isCodexAppServerStartupError(error, "aborted")).toBe(true);
expect((error as Error).message).toBe("codex app-server startup aborted");
expect(harness.process.stdin.destroyed).toBe(true);
});
@@ -800,6 +804,7 @@ describe("startCodexAttemptThread", () => {
const error = await runError;
expect(error).toBeInstanceOf(Error);
expect(isCodexAppServerRequestTimeoutError(error)).toBe(true);
expect((error as Error).message).toBe("plugin/list timed out");
expect(harness.process.stdin.destroyed).toBe(true);
});
@@ -17,9 +17,18 @@ import {
unsubscribeCodexThreadBestEffort,
} from "./attempt-client-cleanup.js";
import { buildCodexPluginThreadConfigEligibilityLogData } from "./attempt-diagnostics.js";
import { withCodexStartupTimeout } from "./attempt-timeouts.js";
import {
CodexAppServerStartupError,
isCodexAppServerStartupError,
withCodexStartupTimeout,
} from "./attempt-timeouts.js";
import { ensureCodexAppServerClientRuntime } from "./client-runtime.js";
import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js";
import {
isCodexAppServerBrokenPipeError,
isCodexAppServerConnectionClosedError,
isCodexAppServerRequestTimeoutError,
type CodexAppServerClient,
} from "./client.js";
import { startCodexComputerUseHealthMonitor } from "./computer-use-health.js";
import { ensureCodexComputerUse } from "./computer-use.js";
import {
@@ -257,10 +266,10 @@ export async function startCodexAttemptThread(params: {
attemptedClient = activeStartupClient;
startupClientForAbandonedRequestCleanup = activeStartupClient;
if (startupAbandoned) {
throw new Error("codex app-server startup timed out");
throw new CodexAppServerStartupError("timed_out");
}
if (startupAbandonController.signal.aborted) {
throw new Error("codex app-server startup aborted");
throw new CodexAppServerStartupError("aborted");
}
let runtimeArtifact: AgentHarnessRuntimeArtifactBinding | undefined;
if (params.runtimeArtifactRequest) {
@@ -372,7 +381,7 @@ export async function startCodexAttemptThread(params: {
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
if (startupAbandonController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
throw new CodexAppServerStartupError("aborted");
}
if (
params.sandbox?.enabled &&
@@ -493,7 +502,7 @@ export async function startCodexAttemptThread(params: {
throw error;
}
if (startupAbandonController.signal.aborted) {
throw new Error("codex app-server startup aborted");
throw new CodexAppServerStartupError("aborted");
}
const startupRoute = startupReservation;
if (!startupRoute) {
@@ -672,17 +681,12 @@ export async function startCodexAttemptThread(params: {
}
function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean {
return (
error instanceof Error &&
(error.message === "codex app-server startup timed out" ||
error.message === "codex app-server startup aborted")
);
return isCodexAppServerStartupError(error);
}
function shouldClearSharedClientAfterStartupRace(error: unknown): boolean {
return (
error instanceof Error &&
(shouldClearSharedClientAfterStartupAbandon(error) || error.message.endsWith(" timed out"))
shouldClearSharedClientAfterStartupAbandon(error) || isCodexAppServerRequestTimeoutError(error)
);
}
@@ -693,7 +697,7 @@ function shouldClearSharedClientAfterStartupFailure(params: {
if (!(params.error instanceof Error)) {
return !params.spawnedBy;
}
if (params.error.message.includes("write EPIPE")) {
if (isCodexAppServerBrokenPipeError(params.error)) {
return true;
}
return !params.spawnedBy;
@@ -2,6 +2,7 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
isCodexAppServerStartupError,
resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs,
resolveCodexGatewayTimeoutWithGraceMs,
resolveCodexStartupTimeoutMs,
@@ -170,12 +171,14 @@ describe("Codex app-server attempt timeouts", () => {
},
operation: async () => new Promise<never>(() => {}),
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
const errorResult = run.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(10);
expect(events).toEqual(["cleanup-start"]);
await vi.advanceTimersByTimeAsync(5);
await rejected;
const error = await errorResult;
expect(isCodexAppServerStartupError(error, "timed_out")).toBe(true);
expect((error as Error).message).toBe("codex app-server startup timed out");
expect(events).toEqual(["cleanup-start", "cleanup-done"]);
});
@@ -187,10 +190,12 @@ describe("Codex app-server attempt timeouts", () => {
signal: controller.signal,
operation: async () => new Promise<never>(() => {}),
});
const rejected = expect(run).rejects.toThrow("codex app-server startup aborted");
const errorResult = run.catch((error: unknown) => error);
controller.abort();
await rejected;
const error = await errorResult;
expect(isCodexAppServerStartupError(error, "aborted")).toBe(true);
expect((error as Error).message).toBe("codex app-server startup aborted");
});
});
@@ -20,6 +20,36 @@ export const CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS = 5 * 60_000;
/** Long terminal idle watch for app-server turns that never send completion. */
const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
type CodexAppServerStartupErrorReason = "aborted" | "timed_out";
export class CodexAppServerStartupError extends Error {
readonly code = "CODEX_APP_SERVER_STARTUP_CANCELLED";
constructor(
readonly reason: CodexAppServerStartupErrorReason,
message = reason === "timed_out"
? "codex app-server startup timed out"
: "codex app-server startup aborted",
) {
super(message);
this.name = "CodexAppServerStartupError";
}
}
export function isCodexAppServerStartupError(
error: unknown,
reason?: CodexAppServerStartupErrorReason,
): error is CodexAppServerStartupError {
return (
error instanceof Error &&
"code" in error &&
error.code === "CODEX_APP_SERVER_STARTUP_CANCELLED" &&
"reason" in error &&
(error.reason === "aborted" || error.reason === "timed_out") &&
(reason === undefined || error.reason === reason)
);
}
function resolvePositiveIntegerTimeoutMs(value: number | undefined, fallbackMs: number): number {
const fallback = resolveTimerTimeoutMs(fallbackMs, 1);
return resolveTimerTimeoutMs(value, fallback);
@@ -33,7 +63,7 @@ export async function withCodexStartupTimeout<T>(params: {
operation: () => Promise<T>;
}): Promise<T> {
if (params.signal.aborted) {
throw new Error("codex app-server startup aborted");
throw new CodexAppServerStartupError("aborted");
}
let timeout: NodeJS.Timeout | undefined;
let abortCleanup: (() => void) | undefined;
@@ -51,7 +81,7 @@ export async function withCodexStartupTimeout<T>(params: {
reject(error);
};
timeout = setTimeout(() => {
timeoutError = new Error("codex app-server startup timed out");
timeoutError = new CodexAppServerStartupError("timed_out");
timeoutCleanup = Promise.resolve(params.onTimeout?.()).then(
() => undefined,
() => undefined,
@@ -60,7 +90,7 @@ export async function withCodexStartupTimeout<T>(params: {
rejectOnce(timeoutError!);
});
}, params.timeoutMs);
const abortListener = () => rejectOnce(new Error("codex app-server startup aborted"));
const abortListener = () => rejectOnce(new CodexAppServerStartupError("aborted"));
params.signal.addEventListener("abort", abortListener, { once: true });
abortCleanup = () => params.signal.removeEventListener("abort", abortListener);
}),
+26 -1
View File
@@ -62,12 +62,14 @@ export function getCodexAppServerClientInstanceId(client: object): string {
export class CodexAppServerRpcError extends Error {
readonly code?: number;
readonly data?: JsonValue;
readonly method: string;
constructor(error: { code?: number; message: string; data?: JsonValue }, method: string) {
super(formatCodexAppServerRpcErrorMessage(error, method));
this.name = "CodexAppServerRpcError";
this.code = error.code;
this.data = error.data;
this.method = method;
}
}
@@ -76,7 +78,7 @@ class CodexAppServerLocalRequestCancellationError extends Error {
constructor(
method: string,
reason: "aborted" | "timed out",
readonly reason: "aborted" | "timed out",
readonly mayHaveWritten: boolean,
) {
super(`${method} ${reason}`);
@@ -84,6 +86,29 @@ class CodexAppServerLocalRequestCancellationError extends Error {
}
}
export function isCodexAppServerRequestTimeoutError(error: unknown): boolean {
return (
error instanceof Error &&
"code" in error &&
error.code === "CODEX_APP_SERVER_LOCAL_REQUEST_CANCELLED" &&
"reason" in error &&
error.reason === "timed out"
);
}
export function isCodexAppServerBrokenPipeError(error: unknown): boolean {
const seen = new Set<unknown>();
let current = error;
while (current && typeof current === "object" && !seen.has(current)) {
seen.add(current);
if ("code" in current && current.code === "EPIPE") {
return true;
}
current = "cause" in current ? current.cause : undefined;
}
return false;
}
class CodexAppServerIndeterminateTransportError extends Error {
readonly code = "CODEX_APP_SERVER_REQUEST_TRANSPORT_INDETERMINATE";
readonly mayHaveWritten = true;
@@ -1406,7 +1406,7 @@ describe("maybeCompactCodexAppServerSession", () => {
const fake = createFakeCodexClient();
fake.request.mockRejectedValueOnce(
new CodexAppServerRpcError(
{ code: -32_602, message: "thread not found: thread-1" },
{ code: -32_600, message: "thread not found: thread-1" },
"thread/compact/start",
),
);
@@ -905,6 +905,13 @@ function isSameNativeCompactionBinding(
}
function isCodexThreadNotFoundError(error: unknown): boolean {
// codex-rs exposes no dedicated error code for a missing compaction thread:
// thread/compact/start returns generic INVALID_REQUEST (-32600), and the
// app-server's own contract/test asserts the "thread not found" MESSAGE as
// the discriminator (thread_processor.rs load_thread → invalid_request;
// compaction.rs asserts message.contains("thread not found")). So the message
// is the authoritative positive signal here, not the generic code. This is a
// self-heal recovery gate, not user-facing classification.
return formatCompactionError(error).toLowerCase().includes("thread not found");
}
@@ -5590,7 +5590,7 @@ describe("runCodexAppServerAttempt", () => {
...mockClientRuntimeMethods(),
request: vi.fn(async (method: string) => {
if (method === "thread/start") {
throw new Error("write EPIPE");
throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
}
return {};
}),
@@ -7,6 +7,7 @@ import path from "node:path";
import type { AgentHarnessRuntimeArtifactBinding } from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveDefaultAgentDir, type AuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import { CodexAppServerStartupError } from "./attempt-timeouts.js";
import {
applyCodexAppServerAuthProfile,
bridgeCodexAppServerStartOptions,
@@ -454,11 +455,14 @@ export async function withLeasedCodexAppServerClientStartSelectionRetry<T>(param
const signal = params.signal ?? params.options?.abandonSignal;
const requestOptions = () => {
if (signal?.aborted) {
throw new Error("Codex app-server selection retry aborted");
throw new CodexAppServerStartupError("aborted", "Codex app-server selection retry aborted");
}
const remainingTimeoutMs = deadline - Date.now();
if (remainingTimeoutMs <= 0) {
throw new Error("Codex app-server selection retry timed out");
throw new CodexAppServerStartupError(
"timed_out",
"Codex app-server selection retry timed out",
);
}
return {
timeoutMs: remainingTimeoutMs,
@@ -507,7 +511,7 @@ async function acquireSharedCodexAppServerClient(
leaseOptions?: { leased: true },
): Promise<{ client: CodexAppServerClient; release?: () => void }> {
if (options?.abandonSignal?.aborted) {
throw new Error("codex app-server initialize aborted");
throw new CodexAppServerStartupError("aborted", "codex app-server initialize aborted");
}
const acquireStartedAt = Date.now();
const timeoutMs = options?.timeoutMs ?? 0;
@@ -653,14 +657,20 @@ async function withCodexAppServerAcquireDeadline<T>(
timeoutMessage = "codex app-server initialize timed out",
): Promise<T> {
if (signal?.aborted) {
throw new Error("codex app-server initialize aborted");
throw new CodexAppServerStartupError("aborted", "codex app-server initialize aborted");
}
const timed = withTimeout(promise, timeoutMs, timeoutMessage);
const timed = withTimeout(
promise,
timeoutMs,
timeoutMessage,
() => new CodexAppServerStartupError("timed_out", timeoutMessage),
);
if (!signal) {
return await timed;
}
return await new Promise<T>((resolve, reject) => {
const onAbort = () => reject(new Error("codex app-server initialize aborted"));
const onAbort = () =>
reject(new CodexAppServerStartupError("aborted", "codex app-server initialize aborted"));
signal.addEventListener("abort", onAbort, { once: true });
timed.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
});
@@ -672,7 +682,7 @@ function resolveRemainingAcquireTimeout(timeoutMs: number, startedAt: number): n
}
const remaining = timeoutMs - (Date.now() - startedAt);
if (remaining <= 0) {
throw new Error("codex app-server initialize timed out");
throw new CodexAppServerStartupError("timed_out", "codex app-server initialize timed out");
}
return remaining;
}
@@ -738,7 +748,7 @@ export async function createIsolatedCodexAppServerClient(
options?: CodexAppServerClientOptions,
): Promise<CodexAppServerClient> {
if (options?.abandonSignal?.aborted) {
throw new Error("codex app-server initialize aborted");
throw new CodexAppServerStartupError("aborted", "codex app-server initialize aborted");
}
const acquireStartedAt = Date.now();
const timeoutMs = options?.timeoutMs ?? 0;
+5 -1
View File
@@ -9,6 +9,10 @@ export async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutMessage: string,
createError?: () => Error,
): Promise<T> {
return await withSharedTimeout(promise, timeoutMs, { message: timeoutMessage });
return await withSharedTimeout(promise, timeoutMs, {
message: timeoutMessage,
...(createError ? { createError } : {}),
});
}
+26 -1
View File
@@ -1,5 +1,30 @@
import { describe, expect, it } from "vitest";
import { mergeUsageSummaries } from "./codex-synthetic-usage.js";
import {
mergeUsageSummaries,
shouldUseCodexSyntheticUsageForRuntime,
} from "./codex-synthetic-usage.js";
describe("shouldUseCodexSyntheticUsageForRuntime", () => {
it("keeps Codex usage enabled after the effective runtime falls back", () => {
expect(
shouldUseCodexSyntheticUsageForRuntime({
provider: "openai",
effectiveHarness: "openclaw",
sessionHarnessId: "codex",
}),
).toBe(true);
});
it("does not enable Codex usage for a never-Codex session", () => {
expect(
shouldUseCodexSyntheticUsageForRuntime({
provider: "openai",
effectiveHarness: "openclaw",
sessionHarnessId: "openclaw",
}),
).toBe(false);
});
});
describe("mergeUsageSummaries", () => {
it("preserves OAuth plan and billing when synthetic Codex windows win", () => {
+4 -1
View File
@@ -46,11 +46,14 @@ export function buildCodexSyntheticUsageAuth(
export function shouldUseCodexSyntheticUsageForRuntime(params: {
provider?: string;
effectiveHarness?: string;
sessionHarnessId?: string;
}): boolean {
const harness = normalizeOptionalLowercaseString(params.effectiveHarness);
const sessionHarness = normalizeOptionalLowercaseString(params.sessionHarnessId);
const provider = normalizeOptionalLowercaseString(params.provider);
return (
harness === CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER &&
(harness === CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER ||
sessionHarness === CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER) &&
(provider === CODEX_SYNTHETIC_USAGE_PROVIDER || provider === "codex")
);
}
+77
View File
@@ -5,6 +5,7 @@ import { buildStatusText } from "./status-text.js";
const mocks = vi.hoisted(() => ({
loadSessionCostSummariesFromCache: vi.fn(),
loadProviderUsageSummary: vi.fn(),
}));
vi.mock("../infra/session-cost-usage.js", async (importOriginal) => {
@@ -15,6 +16,14 @@ vi.mock("../infra/session-cost-usage.js", async (importOriginal) => {
};
});
vi.mock("../infra/provider-usage.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/provider-usage.js")>();
return {
...actual,
loadProviderUsageSummary: mocks.loadProviderUsageSummary,
};
});
type StatusTextParams = Parameters<typeof buildStatusText>[0];
async function renderTelegramStatus(params: {
@@ -109,6 +118,74 @@ describe("buildStatusText channel features", () => {
});
});
describe("Codex usage after runtime fallback", () => {
beforeEach(() => {
mocks.loadProviderUsageSummary.mockReset();
mocks.loadProviderUsageSummary.mockImplementation(async (params) => ({
updatedAt: Date.now(),
providers: params.auth
? [
{
provider: "openai",
displayName: "Codex",
windows: [{ label: "5h", usedPercent: 25 }],
},
]
: [],
}));
});
async function renderFallbackStatus(agentHarnessId: "codex" | "openclaw"): Promise<string> {
return await buildStatusText({
cfg: {},
sessionEntry: {
sessionId: `fallback-${agentHarnessId}`,
updatedAt: 0,
agentRuntimeOverride: "openclaw",
agentHarnessId,
},
sessionKey: "agent:main:main",
statusChannel: "mobilechat",
provider: "openai",
model: "gpt-5.4-mini",
resolvedHarness: "openclaw",
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
defaultGroupActivation: () => "mention",
pluginHealthLineOverride: "Plugins: test",
taskLineOverride: "",
skipDefaultTaskLookup: true,
primaryModelLabelOverride: "openai/gpt-5.4-mini",
modelAuthOverride: "oauth",
activeModelAuthOverride: "oauth",
includeTranscriptUsage: false,
});
}
it("shows Codex rate-limit usage for a Codex-bound session on OpenClaw Default", async () => {
const text = await renderFallbackStatus("codex");
expect(text).toContain("📊 Usage: 5h 75% left");
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith(
expect.objectContaining({
providers: ["openai"],
auth: [expect.objectContaining({ provider: "openai", hookProvider: "codex" })],
}),
);
});
it("omits Codex rate-limit usage for a never-Codex session", async () => {
const text = await renderFallbackStatus("openclaw");
expect(text).not.toContain("📊 Usage:");
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith(
expect.not.objectContaining({ auth: expect.anything() }),
);
});
});
describe("session status cost line", () => {
const sessionEntry = {
sessionId: "cost-session",
+2
View File
@@ -419,6 +419,8 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
shouldUseCodexSyntheticUsageForRuntime({
provider: usageStatusProvider,
effectiveHarness,
// A runtime fallback does not erase the session's Codex binding or its rate limits.
sessionHarnessId: sessionEntry?.agentHarnessId,
});
const codexUsageAuthProfileId = useCodexSyntheticUsage
? resolveCodexSyntheticUsageAuthProfileId({