mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
44
extensions/codex/src/app-server/rate-limits.test.ts
Normal file
44
extensions/codex/src/app-server/rate-limits.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: <\uff20U123> 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"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user