Show Codex subscription reset times in channel errors (#80456)

* fix(codex): refresh subscription limit resets

* fix(codex): format reset times for channels

* Update CHANGELOG with latest changes and fixes

Updated CHANGELOG with recent fixes and improvements.

* fix(codex): keep command load failures on codex surface

* fix(codex): format account rate limits as rows

* fix(codex): summarize account limits as usage status

* fix(codex): simplify account limit status
This commit is contained in:
pashpashpash
2026-05-10 17:42:06 -07:00
committed by GitHub
parent 8ffb756614
commit c3af812fe3
9 changed files with 564 additions and 34 deletions

View File

@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
- Auth/Claude CLI: persist fresher managed external CLI OAuth credentials back to `auth-profiles.json`, preventing stale `anthropic:claude-cli` profiles from repeatedly bootstrapping and flooding debug logs. Fixes #80129. Thanks @Caulderein.
- Context: render `/context map` only from actual run context and persist Codex app-server run reports without counting deferred tool-search schemas as prompt-loaded tool schemas.
- Codex app-server: report Codex-native tool execution to diagnostics so long-running native `bash`, web, file, and MCP tools no longer look like stale embedded runs to the watchdog. (#80217)
- Codex app-server: refresh Codex account rate limits after subscription usage-limit failures so Discord and other channel replies can show the next reset time instead of saying Codex returned none. Thanks @pashpashpash.
- Tasks: route group and channel task completions through the requester session so the parent agent can send the visible summary instead of stopping at a generic task-status line. Fixes #77251. (#77365) Thanks @funmerlin.
- Telegram: preserve blank lines between manually indented bullet blocks and following numbered sections in rendered replies. Fixes #76998. Thanks @evgyur.
- Slack: pass configured agent identity through draft preview sends so partial streaming replies keep custom username/avatar on the initial Slack message. Fixes #38235. (#38237) Thanks @lacymorrow.

View File

@@ -526,6 +526,33 @@ describe("CodexAppServerEventProjector", () => {
expect(result.promptErrorSource).toBe("prompt");
});
it("preserves Codex retry hints when failed turns omit structured reset details", async () => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: {
id: TURN_ID,
status: "failed",
error: {
message:
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at May 11th, 2026 9:00 AM.",
codexErrorInfo: "usageLimitExceeded",
additionalDetails: null,
},
items: [],
},
}),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.promptError).toContain("You've reached your Codex subscription usage limit.");
expect(result.promptError).toContain("Codex says to try again at May 11th, 2026 9:00 AM.");
expect(result.promptError).not.toContain("Codex did not return a reset time");
expect(result.promptErrorSource).toBe("prompt");
});
it("normalizes snake_case current token usage fields", async () => {
const projector = await createProjector();

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
describe("formatCodexUsageLimitErrorMessage", () => {
it("preserves Codex retry hints when structured reset windows are absent", () => {
const message = formatCodexUsageLimitErrorMessage({
message:
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at May 11th, 2026 9:00 AM.",
codexErrorInfo: "usageLimitExceeded",
rateLimits: {
rateLimits: {
limitId: "codex",
primary: { usedPercent: 100, windowDurationMins: 300, resetsAt: null },
secondary: null,
},
},
nowMs: Date.UTC(2026, 4, 10, 23, 0, 0),
});
expect(message).toContain("You've reached your Codex subscription usage limit.");
expect(message).toContain("Codex says to try again at May 11th, 2026 9:00 AM.");
expect(message).not.toContain("Codex did not return a reset time");
});
it("accepts snake_case rate limit snapshots from Codex core payloads", () => {
const message = formatCodexUsageLimitErrorMessage({
message: "You've reached your usage limit.",
codexErrorInfo: "usageLimitExceeded",
rateLimits: {
rate_limits: {
limit_id: "codex",
primary: { used_percent: 100, window_minutes: 300, resets_at: 1_700_003_600 },
secondary: null,
},
},
nowMs: 1_700_000_000_000,
});
expect(message).toContain("Next reset in 1 hour, ");
expect(message).toMatch(/\b[A-Z][a-z]{2} \d{1,2}(?:, \d{4})? at \d{1,2}:\d{2} [AP]M\b/u);
expect(message).not.toMatch(/\(\d{4}-\d{2}-\d{2}T/u);
expect(message).not.toContain("Codex did not return a reset time");
});
});

View File

@@ -11,6 +11,12 @@ type LimitWindowKey = (typeof LIMIT_WINDOW_KEYS)[number];
type RateLimitReset = {
resetsAtMs: number;
usedPercent?: number;
windowDurationMins?: number;
};
type RateLimitWindowEntry = {
key: LimitWindowKey;
window: RateLimitReset;
};
export function formatCodexUsageLimitErrorMessage(params: {
@@ -29,12 +35,27 @@ export function formatCodexUsageLimitErrorMessage(params: {
if (nextReset) {
parts.push(`Next reset ${formatResetTime(nextReset.resetsAtMs, nowMs)}.`);
} else {
parts.push("Codex did not return a reset time for this limit.");
const codexRetryHint = extractCodexRetryHint(message);
if (codexRetryHint) {
parts.push(`Codex says to try again ${codexRetryHint}.`);
} else {
parts.push("Codex did not return a reset time for this limit.");
}
}
parts.push("Run /codex account for current usage details.");
return parts.join(" ");
}
export function shouldRefreshCodexRateLimitsForUsageLimitMessage(
message: string | null | undefined,
): boolean {
const text = normalizeText(message);
return Boolean(
text?.includes("You've reached your Codex subscription usage limit.") &&
!text.includes("Next reset "),
);
}
export function summarizeCodexRateLimits(
value: JsonValue | undefined,
nowMs = Date.now(),
@@ -49,6 +70,29 @@ export function summarizeCodexRateLimits(
.join("; ");
}
export function summarizeCodexAccountRateLimits(
value: JsonValue | undefined,
nowMs = Date.now(),
): string[] | undefined {
const snapshots = collectCodexRateLimitSnapshots(value);
if (snapshots.length === 0) {
return undefined;
}
const blockedSnapshots = snapshots.filter(snapshotHasLimitBlock);
const blockingSnapshot =
blockedSnapshots.find(isCodexLimitSnapshot) ?? blockedSnapshots[0] ?? undefined;
if (!blockingSnapshot) {
return ["Codex is available."];
}
const blockingReset = selectSnapshotBlockingReset(blockingSnapshot, nowMs);
return [
blockingReset
? `Codex is paused until ${formatAccountResetTime(blockingReset.resetsAtMs, nowMs)}.`
: "Codex is paused by a usage limit.",
formatBlockingLimitReason(blockingReset),
];
}
function isCodexUsageLimitError(
codexErrorInfo: JsonValue | null | undefined,
message: string | undefined,
@@ -90,7 +134,8 @@ function summarizeRateLimitSnapshot(snapshot: JsonObject, nowMs: number): string
const window = readRateLimitWindow(snapshot, key);
return window ? [formatRateLimitWindow(key, window, nowMs)] : [];
});
const reachedType = readString(snapshot, "rateLimitReachedType");
const reachedType =
readString(snapshot, "rateLimitReachedType") ?? readString(snapshot, "rate_limit_reached_type");
const suffix = reachedType ? ` (${formatReachedType(reachedType)})` : "";
return `${label}: ${windows.join(", ") || "available"}${suffix}`;
}
@@ -126,7 +171,14 @@ function collectRateLimitSnapshots(
collectRateLimitSnapshots(byLimitId[key], snapshots, seen);
}
}
const snakeByLimitId = value.rate_limits_by_limit_id;
if (isJsonObject(snakeByLimitId)) {
for (const key of sortedRateLimitKeys(Object.keys(snakeByLimitId))) {
collectRateLimitSnapshots(snakeByLimitId[key], snapshots, seen);
}
}
collectRateLimitSnapshots(value.rateLimits, snapshots, seen);
collectRateLimitSnapshots(value.rate_limits, snapshots, seen);
collectRateLimitSnapshots(value.data, snapshots, seen);
collectRateLimitSnapshots(value.items, snapshots, seen);
}
@@ -149,8 +201,8 @@ function addRateLimitSnapshot(
seen: Set<string>,
): void {
const signature = [
readNullableString(snapshot, "limitId") ?? "",
readNullableString(snapshot, "limitName") ?? "",
readNullableString(snapshot, "limitId") ?? readNullableString(snapshot, "limit_id") ?? "",
readNullableString(snapshot, "limitName") ?? readNullableString(snapshot, "limit_name") ?? "",
formatWindowSignature(snapshot.primary),
formatWindowSignature(snapshot.secondary),
].join("|");
@@ -166,8 +218,11 @@ function isRateLimitSnapshot(value: JsonObject): boolean {
isJsonObject(value.primary) ||
isJsonObject(value.secondary) ||
value.rateLimitReachedType !== undefined ||
value.rate_limit_reached_type !== undefined ||
value.limitId !== undefined ||
value.limitName !== undefined
value.limit_id !== undefined ||
value.limitName !== undefined ||
value.limit_name !== undefined
);
}
@@ -179,31 +234,53 @@ function readRateLimitWindow(
if (!isJsonObject(window)) {
return undefined;
}
const resetsAt = readNumber(window, "resetsAt");
const resetsAt = readNumber(window, "resetsAt") ?? readNumber(window, "resets_at");
return {
...(typeof resetsAt === "number" && Number.isFinite(resetsAt) && resetsAt > 0
? { resetsAtMs: resetsAt * 1000 }
: { resetsAtMs: 0 }),
...readOptionalNumberField(window, "usedPercent"),
...readOptionalNumberField(window, "usedPercent", "used_percent"),
...readOptionalNumberField(
window,
"windowDurationMins",
"window_duration_mins",
"windowMinutes",
"window_minutes",
),
};
}
function readOptionalNumberField(record: JsonObject, key: string): { usedPercent?: number } {
const value = readNumber(record, key);
return value === undefined ? {} : { usedPercent: value };
function readOptionalNumberField(
record: JsonObject,
...keys: string[]
): { usedPercent?: number; windowDurationMins?: number } {
const value = keys.map((key) => readNumber(record, key)).find((entry) => entry !== undefined);
if (value === undefined) {
return {};
}
return keys.some((key) => key.toLowerCase().includes("window"))
? { windowDurationMins: value }
: { usedPercent: value };
}
function formatRateLimitWindow(key: LimitWindowKey, window: RateLimitReset, nowMs: number): string {
return `${key} ${formatRateLimitWindowDetails(window, nowMs)}`;
}
function formatRateLimitWindowDetails(window: RateLimitReset, nowMs: number): string {
const usedPercent =
window.usedPercent === undefined ? "usage unknown" : `${Math.round(window.usedPercent)}%`;
const reset =
window.resetsAtMs > nowMs ? `, resets ${formatResetTime(window.resetsAtMs, nowMs)}` : "";
return `${key} ${usedPercent}${reset}`;
return `${usedPercent}${reset}`;
}
function formatLimitLabel(snapshot: JsonObject): string {
const label =
readNullableString(snapshot, "limitName") ?? readNullableString(snapshot, "limitId");
readNullableString(snapshot, "limitName") ??
readNullableString(snapshot, "limit_name") ??
readNullableString(snapshot, "limitId") ??
readNullableString(snapshot, "limit_id");
if (!label || label === CODEX_LIMIT_ID) {
return "Codex";
}
@@ -215,7 +292,96 @@ function formatReachedType(value: string): string {
}
function formatResetTime(resetsAtMs: number, nowMs: number): string {
return `in ${formatRelativeDuration(resetsAtMs - nowMs)} (${new Date(resetsAtMs).toISOString()})`;
return `in ${formatRelativeDuration(resetsAtMs - nowMs)}, ${formatCalendarResetTime(
resetsAtMs,
nowMs,
)}`;
}
function formatAccountResetTime(resetsAtMs: number, nowMs: number): string {
return `${formatCalendarResetTime(resetsAtMs, nowMs)} (in ${formatRelativeDuration(
resetsAtMs - nowMs,
)})`;
}
function snapshotHasLimitBlock(snapshot: JsonObject): boolean {
return Boolean(
readString(snapshot, "rateLimitReachedType") ??
readString(snapshot, "rate_limit_reached_type") ??
readWindowEntries(snapshot).some(
(entry) => entry.window.usedPercent !== undefined && entry.window.usedPercent >= 100,
),
);
}
function isCodexLimitSnapshot(snapshot: JsonObject): boolean {
const id = readNullableString(snapshot, "limitId") ?? readNullableString(snapshot, "limit_id");
return !id || id === CODEX_LIMIT_ID;
}
function selectSnapshotBlockingReset(
snapshot: JsonObject,
nowMs: number,
): RateLimitReset | undefined {
const futureWindows = readWindowEntries(snapshot)
.map((entry) => entry.window)
.filter((window) => window.resetsAtMs > nowMs);
const exhaustedWindows = futureWindows.filter(
(window) => window.usedPercent !== undefined && window.usedPercent >= 100,
);
const candidates = exhaustedWindows.length > 0 ? exhaustedWindows : futureWindows;
candidates.sort((left, right) => left.resetsAtMs - right.resetsAtMs);
return candidates[0];
}
function readWindowEntries(snapshot: JsonObject): RateLimitWindowEntry[] {
return LIMIT_WINDOW_KEYS.flatMap((key) => {
const window = readRateLimitWindow(snapshot, key);
return window ? [{ key, window }] : [];
});
}
function formatBlockingLimitReason(window: RateLimitReset | undefined): string {
const period = formatBlockingLimitPeriod(window?.windowDurationMins);
return period
? `Your ${period} Codex usage limit is reached.`
: "Your Codex usage limit is reached.";
}
function formatBlockingLimitPeriod(minutes: number | undefined): string | undefined {
if (minutes === 7 * 24 * 60) {
return "weekly";
}
if (minutes === 24 * 60) {
return "daily";
}
if (minutes !== undefined && minutes > 0 && minutes < 24 * 60) {
return "short-term";
}
return undefined;
}
function formatCalendarResetTime(resetsAtMs: number, nowMs: number): string {
const resetDate = new Date(resetsAtMs);
const resetParts = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
...(resetDate.getFullYear() === new Date(nowMs).getFullYear() ? {} : { year: "numeric" }),
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
}).formatToParts(resetDate);
const part = (type: Intl.DateTimeFormatPartTypes): string | undefined =>
resetParts.find((entry) => entry.type === type)?.value;
const dateParts = [part("month"), part("day"), part("year")].filter(Boolean);
const day =
dateParts.length > 1 ? `${dateParts[0]} ${dateParts.slice(1).join(", ")}` : dateParts[0];
const time = [part("hour"), part("minute")].filter(Boolean).join(":");
const dayPeriod = part("dayPeriod");
const timeZone = part("timeZoneName");
return [day, "at", [time, dayPeriod, timeZone].filter(Boolean).join(" ")]
.filter(Boolean)
.join(" ");
}
function formatRelativeDuration(durationMs: number): string {
@@ -239,7 +405,23 @@ function formatWindowSignature(value: JsonValue | undefined): string {
if (!isJsonObject(value)) {
return "";
}
return `${readNumber(value, "usedPercent") ?? ""}:${readNumber(value, "resetsAt") ?? ""}`;
return `${readNumber(value, "usedPercent") ?? readNumber(value, "used_percent") ?? ""}:${
readNumber(value, "resetsAt") ?? readNumber(value, "resets_at") ?? ""
}`;
}
function extractCodexRetryHint(message: string | undefined): string | undefined {
if (!message) {
return undefined;
}
const tryAgainAt = /\btry again\s+(at\s+[^.!?\n]+)(?:[.!?]|$)/iu.exec(message);
if (tryAgainAt?.[1]) {
return tryAgainAt[1].trim();
}
const tryAgainRelative = /\btry again\s+((?:tomorrow|in\s+[^.!?\n]+)[^.!?\n]*)(?:[.!?]|$)/iu.exec(
message,
);
return tryAgainRelative?.[1]?.trim();
}
function readString(record: JsonObject, key: string): string | undefined {

View File

@@ -2211,6 +2211,36 @@ describe("runCodexAppServerAttempt", () => {
expect((error as Error).message).toContain("Next reset in");
});
it("refreshes Codex account rate limits when turn/start omits reset details", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
const harness = createStartedThreadHarness(async (method) => {
if (method === "turn/start") {
throw Object.assign(new Error("You've reached your usage limit."), {
data: { codexErrorInfo: "usageLimitExceeded" },
});
}
if (method === "account/rateLimits/read") {
return rateLimitsUpdated(resetsAt).params;
}
return undefined;
});
const runError = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)).catch(
(error: unknown) => error,
);
await harness.waitForMethod("account/rateLimits/read");
const error = await runError;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"You've reached your Codex subscription usage limit.",
);
expect((error as Error).message).toContain("Next reset in");
expect((error as Error).message).not.toContain("Codex did not return a reset time");
});
it("cleans up native hook relay state when the Codex turn aborts", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -2230,6 +2260,45 @@ describe("runCodexAppServerAttempt", () => {
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
});
it("refreshes Codex account rate limits when a failed turn omits reset details", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
const harness = createStartedThreadHarness(async (method) => {
if (method === "account/rateLimits/read") {
return rateLimitsUpdated(resetsAt).params;
}
return undefined;
});
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
await harness.waitForMethod("turn/start");
await harness.notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: {
id: "turn-1",
status: "failed",
error: {
message: "You've reached your usage limit.",
codexErrorInfo: "usageLimitExceeded",
},
},
},
});
const result = await run;
expect(result.promptError).toContain("You've reached your Codex subscription usage limit.");
expect(result.promptError).toContain("Next reset in");
expect(result.promptError).not.toContain("Codex did not return a reset time");
expect(harness.requests.some((request) => request.method === "account/rateLimits/read")).toBe(
true,
);
});
it("fires agent_end with failure metadata when the codex turn fails", async () => {
const agentEnd = vi.fn();
const onRunAgentEvent = vi.fn();

View File

@@ -54,6 +54,7 @@ import {
resolveCodexAppServerAuthProfileId,
resolveCodexAppServerAuthProfileIdForAgent,
} from "./auth-bridge.js";
import { CODEX_CONTROL_METHODS } from "./capabilities.js";
import {
defaultCodexAppServerClientFactory,
type CodexAppServerClientFactory,
@@ -103,7 +104,10 @@ import {
type JsonValue,
} from "./protocol.js";
import { readRecentCodexRateLimits, rememberCodexRateLimits } from "./rate-limit-cache.js";
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
import {
formatCodexUsageLimitErrorMessage,
shouldRefreshCodexRateLimitsForUsageLimitMessage,
} from "./rate-limits.js";
import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./session-binding.js";
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
import { clearSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
@@ -135,6 +139,7 @@ const CODEX_DYNAMIC_TOOL_MAX_TIMEOUT_MS = 600_000;
const CODEX_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS = 60_000;
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 3;
const CODEX_APP_SERVER_STARTUP_TIMEOUT_FLOOR_MS = 100;
const CODEX_USAGE_LIMIT_RATE_LIMIT_REFRESH_TIMEOUT_MS = 5_000;
const CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS = 60_000;
const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
const CODEX_NATIVE_HOOK_RELAY_MIN_TTL_MS = 30 * 60_000;
@@ -1300,7 +1305,13 @@ export async function runCodexAppServerAttempt(
),
);
} catch (error) {
const usageLimitError = formatCodexTurnStartUsageLimitError(error, pendingNotifications);
const usageLimitError = await formatCodexTurnStartUsageLimitError({
client,
error,
pendingNotifications,
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
});
const turnStartErrorMessage = usageLimitError ?? formatErrorMessage(error);
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
@@ -1435,11 +1446,29 @@ export async function runCodexAppServerAttempt(
await completion;
const result = activeProjector.buildResult(toolBridge.telemetry, { yieldDetected });
const finalAborted = result.aborted || runAbortController.signal.aborted;
const finalPromptError = turnCompletionIdleTimedOut
let finalPromptError = turnCompletionIdleTimedOut
? turnCompletionIdleTimeoutMessage
: timedOut
? "codex app-server attempt timed out"
: result.promptError;
const finalPromptErrorMessage =
typeof finalPromptError === "string"
? finalPromptError
: finalPromptError
? formatErrorMessage(finalPromptError)
: undefined;
if (shouldRefreshCodexRateLimitsForUsageLimitMessage(finalPromptErrorMessage)) {
finalPromptError = await refreshCodexUsageLimitErrorMessage({
client,
source: {
message: finalPromptErrorMessage,
codexErrorInfo: "usageLimitExceeded",
rateLimits: readRecentCodexRateLimits(),
},
timeoutMs: appServer.requestTimeoutMs,
signal: runAbortController.signal,
});
}
const finalPromptErrorSource = timedOut ? "prompt" : result.promptErrorSource;
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt: params,
@@ -2047,20 +2076,97 @@ function readDynamicToolCallParams(
return readCodexDynamicToolCallParams(value);
}
function formatCodexTurnStartUsageLimitError(
type CodexUsageLimitErrorSource = {
message?: string | null;
codexErrorInfo?: JsonValue | null;
rateLimits?: JsonValue;
};
async function formatCodexTurnStartUsageLimitError(params: {
client: CodexAppServerClient;
error: unknown;
pendingNotifications: CodexServerNotification[];
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<string | undefined> {
return refreshCodexUsageLimitErrorMessage({
client: params.client,
source: readCodexTurnStartUsageLimitErrorSource(params.error, params.pendingNotifications),
timeoutMs: params.timeoutMs,
signal: params.signal,
});
}
async function refreshCodexUsageLimitErrorMessage(params: {
client: CodexAppServerClient;
source: CodexUsageLimitErrorSource;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<string | undefined> {
const initialMessage = formatCodexUsageLimitErrorMessage(params.source);
if (!shouldRefreshCodexRateLimitsForUsageLimitMessage(initialMessage)) {
return initialMessage ?? undefined;
}
const rateLimits = await readCodexRateLimitsFromAppServerForUsageLimitError({
client: params.client,
timeoutMs: params.timeoutMs,
signal: params.signal,
});
if (!rateLimits) {
return initialMessage;
}
const refreshedMessage = formatCodexUsageLimitErrorMessage({
message: params.source.message,
codexErrorInfo: params.source.codexErrorInfo,
rateLimits,
});
return refreshedMessage ?? initialMessage;
}
async function readCodexRateLimitsFromAppServerForUsageLimitError(params: {
client: CodexAppServerClient;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<JsonValue | undefined> {
if (params.signal?.aborted) {
return undefined;
}
try {
const rateLimits = await params.client.request(CODEX_CONTROL_METHODS.rateLimits, undefined, {
timeoutMs: resolveCodexUsageLimitRateLimitRefreshTimeoutMs(params.timeoutMs),
signal: params.signal,
});
rememberCodexRateLimits(rateLimits);
return rateLimits;
} catch (error) {
embeddedAgentLog.debug("codex app-server rate-limit refresh failed after usage-limit error", {
error: formatErrorMessage(error),
});
return undefined;
}
}
function resolveCodexUsageLimitRateLimitRefreshTimeoutMs(timeoutMs: number | undefined): number {
if (timeoutMs === undefined || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return CODEX_USAGE_LIMIT_RATE_LIMIT_REFRESH_TIMEOUT_MS;
}
return Math.max(100, Math.min(timeoutMs, CODEX_USAGE_LIMIT_RATE_LIMIT_REFRESH_TIMEOUT_MS));
}
function readCodexTurnStartUsageLimitErrorSource(
error: unknown,
pendingNotifications: CodexServerNotification[],
): string | undefined {
): CodexUsageLimitErrorSource {
const notificationError = readLatestCodexErrorNotification(pendingNotifications);
const errorPayload = readCodexErrorPayload(error);
return formatCodexUsageLimitErrorMessage({
return {
message: notificationError?.message ?? errorPayload.message ?? formatErrorMessage(error),
codexErrorInfo: notificationError?.codexErrorInfo ?? errorPayload.codexErrorInfo,
rateLimits:
readLatestRateLimitNotificationPayload(pendingNotifications) ??
errorPayload.rateLimits ??
readRecentCodexRateLimits(),
});
};
}
function readLatestRateLimitNotificationPayload(

View File

@@ -1,7 +1,10 @@
import type { CodexComputerUseStatus } from "./app-server/computer-use.js";
import type { CodexAppServerModelListResult } from "./app-server/models.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js";
import { summarizeCodexRateLimits } from "./app-server/rate-limits.js";
import {
summarizeCodexAccountRateLimits,
summarizeCodexRateLimits,
} from "./app-server/rate-limits.js";
import type { SafeValue } from "./command-rpc.js";
type CodexStatusProbes = {
@@ -103,12 +106,18 @@ export function formatAccount(
account: SafeValue<JsonValue | undefined>,
limits: SafeValue<JsonValue | undefined>,
): string {
const formattedLimits = limits.ok
? formatCodexRateLimitDetails(limits.value)
: formatCodexDisplayText(limits.error);
const rateLimitBlock = formattedLimits.startsWith("Codex is ")
? formattedLimits
: formattedLimits.includes("\n")
? `Rate limits:\n${formattedLimits}`
: `Rate limits: ${formattedLimits}`;
return [
`Account: ${account.ok ? formatCodexAccountSummary(account.value) : formatCodexDisplayText(account.error)}`,
`Rate limits: ${
limits.ok ? formatCodexRateLimitSummary(limits.value) : formatCodexDisplayText(limits.error)
}`,
].join("\n");
rateLimitBlock,
].join("\n\n");
}
export function formatComputerUseStatus(status: CodexComputerUseStatus): string {
@@ -283,6 +292,14 @@ function formatCodexRateLimitSummary(value: JsonValue | undefined): string {
return formatCodexDisplayText(summarizeCodexRateLimits(value) ?? summarizeRateLimits(value));
}
function formatCodexRateLimitDetails(value: JsonValue | undefined): string {
const lines = summarizeCodexAccountRateLimits(value);
if (!lines) {
return formatCodexDisplayText(summarizeRateLimits(value));
}
return lines.map(formatCodexDisplayText).join("\n");
}
function summarizeRateLimits(value: JsonValue | undefined): string {
const entries = extractArray(value);
if (entries.length > 0) {

View File

@@ -141,6 +141,17 @@ describe("codex command", () => {
expect(result.text).not.toContain("<@U123>");
});
it("keeps command loader failures on the Codex command surface", async () => {
const result = await handleCodexCommand(createContext("account"), {
loadSubcommandHandler: async () => {
throw new Error("<@U123> loader failed");
},
});
expect(result.text).toContain("Codex command failed: &lt;\uff20U123&gt; loader failed");
expect(result.text).not.toContain("<@U123>");
});
it("attaches the current session to an existing Codex thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const requests: Array<{ method: string; params: unknown }> = [];
@@ -455,7 +466,7 @@ describe("codex command", () => {
const statusResult = await handleCodexCommand(createContext("status"), { deps });
expectResultTextContains(statusResult, "Rate limits: Codex: primary 42%");
const accountResult = await handleCodexCommand(createContext("account"), { deps });
expectResultTextContains(accountResult, "Rate limits: Codex: primary 42%");
expectResultTextContains(accountResult, "Codex is available.");
});
it("rejects extra operands for read-only Codex commands", async () => {
@@ -536,7 +547,7 @@ describe("codex command", () => {
});
expect(result.text).toContain("Account: codex@example.com");
expect(result.text).toContain("Rate limits: Codex: primary 50%, resets in");
expect(result.text).toContain("Codex is available.");
const cachedLimits = requireRecord(
readRecentCodexRateLimits(),
"expected cached Codex rate limits",
@@ -568,6 +579,60 @@ describe("codex command", () => {
expect(result.text).not.toContain("@here");
});
it("summarizes blocked account rate limits as a human takeaway", async () => {
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
const safeCodexControlRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
value: {
account: { type: "chatgpt", email: "codex@example.com", planType: "pro" },
requiresOpenaiAuth: false,
},
})
.mockResolvedValueOnce({
ok: true,
value: {
rateLimitsByLimitId: {
codex: {
limitId: "codex",
limitName: "Codex",
primary: { usedPercent: 0, windowDurationMins: 300, resetsAt },
secondary: { usedPercent: 100, windowDurationMins: 10080, resetsAt: resetsAt + 3600 },
credits: null,
planType: "plus",
rateLimitReachedType: "rate_limit_reached",
},
"gpt-5.3-codex-spark": {
limitId: "gpt-5.3-codex-spark",
limitName: "GPT 5.3 Codex Spark",
primary: { usedPercent: 0, windowDurationMins: 300, resetsAt },
secondary: { usedPercent: 0, windowDurationMins: 10080, resetsAt: resetsAt + 3600 },
credits: null,
planType: "plus",
rateLimitReachedType: null,
},
},
},
});
const result = await handleCodexCommand(createContext("account"), {
deps: createDeps({ safeCodexControlRequest }),
});
expect(result.text).toContain("Codex is paused until ");
expect(result.text).toContain("Your weekly Codex usage limit is reached.");
expect(result.text).not.toContain("GPT 5.3 Codex Spark");
expect(result.text).not.toContain("Primary:");
expect(result.text).not.toContain("Secondary:");
expect(result.text).not.toContain("Bucket:");
expect(result.text).not.toContain("Why:");
expect(result.text).not.toContain("5-hour");
expect(result.text).not.toContain("100%");
expect(result.text).not.toContain("; GPT 5.3 Codex Spark");
expect(result.text).not.toContain("\uff08rate limit reached\uff09");
});
it("escapes successful Codex account fallback summaries before chat display", async () => {
const unsafe = "<@U123> [trusted](https://evil) @here";
const safeCodexControlRequest = vi
@@ -601,7 +666,7 @@ describe("codex command", () => {
deps: createDeps({ safeCodexControlRequest }),
}),
).resolves.toEqual({
text: ["Account: Amazon Bedrock", "Rate limits: none returned"].join("\n"),
text: ["Account: Amazon Bedrock", "Rate limits: none returned"].join("\n\n"),
});
});

View File

@@ -7,10 +7,21 @@ import { describeControlFailure } from "./app-server/capabilities.js";
import { formatCodexDisplayText } from "./command-formatters.js";
import type { CodexCommandDeps } from "./command-handlers.js";
export function createCodexCommand(options: {
type CodexCommandOptions = {
pluginConfig?: unknown;
deps?: Partial<CodexCommandDeps>;
}): OpenClawPluginCommandDefinition {
};
type CodexSubcommandHandler = (
ctx: PluginCommandContext,
options: CodexCommandOptions,
) => Promise<PluginCommandResult>;
type CodexCommandInternalOptions = CodexCommandOptions & {
loadSubcommandHandler?: () => Promise<CodexSubcommandHandler>;
};
export function createCodexCommand(options: CodexCommandOptions): OpenClawPluginCommandDefinition {
return {
name: "codex",
description: "Inspect and control the Codex app-server harness",
@@ -27,14 +38,22 @@ export function createCodexCommand(options: {
export async function handleCodexCommand(
ctx: PluginCommandContext,
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> } = {},
options: CodexCommandInternalOptions = {},
): Promise<PluginCommandResult> {
const { handleCodexSubcommand } = await import("./command-handlers.js");
const { loadSubcommandHandler, ...subcommandOptions } = options;
try {
return await handleCodexSubcommand(ctx, options);
const handleCodexSubcommand = loadSubcommandHandler
? await loadSubcommandHandler()
: await loadDefaultCodexSubcommandHandler();
return await handleCodexSubcommand(ctx, subcommandOptions);
} catch (error) {
return {
text: `Codex command failed: ${formatCodexDisplayText(describeControlFailure(error))}`,
};
}
}
async function loadDefaultCodexSubcommandHandler(): Promise<CodexSubcommandHandler> {
const { handleCodexSubcommand } = await import("./command-handlers.js");
return handleCodexSubcommand;
}