Files
openclaw/extensions/codex/src/app-server/attempt-timeouts.test.ts
alkor2000 4076ba0bd9 fix(codex): derive terminal-idle watchdog from explicit run timeout (#85296)
* fix(codex): derive terminal-idle watchdog from effective run timeout

Fixes the early-abort half of #85242. The Codex app-server terminal-idle
watchdog used a hardcoded 30-minute default that was not derived from the
effective run timeout, so a scheduled turn configured with a longer
timeoutSeconds could be aborted early at 30 minutes even with budget left.

resolveCodexTurnTerminalIdleTimeoutMs (now in attempt-timeouts.ts after the
upstream split) accepts the effective run timeout and, with no explicit
override, follows the run budget instead of the 30-minute default:
- explicit override always wins (advanced config / tests)
- otherwise terminal-idle = max(30min floor, run budget), so a longer run is
  no longer cut short and existing protection is never shortened
- falls back to the 30min default when no run budget is known

Reuses the existing resolvePositiveIntegerTimeoutMs helper, matching the
neighbouring post-tool resolver. Adds focused unit tests for the derivation.

The diagnostic-wording half of #85242 (naming the terminal-idle watchdog in
the surfaced guidance) is left as a separate follow-up.

* fix(codex): derive terminal-idle watchdog from effective run timeout

* fix(codex): preserve default terminal idle watchdog

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-07-01 03:25:13 -07:00

196 lines
7.7 KiB
TypeScript

// Codex tests cover attempt timeouts plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CODEX_APP_SERVER_STARTUP_TIMEOUT_FLOOR_MS,
CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
CODEX_TURN_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS,
CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS,
resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs,
resolveCodexGatewayTimeoutWithGraceMs,
resolveCodexStartupTimeoutMs,
resolveCodexTurnAssistantCompletionIdleTimeoutMs,
resolveCodexTurnCompletionIdleTimeoutMs,
resolveCodexTurnTerminalIdleTimeoutMs,
withCodexStartupTimeout,
} from "./attempt-timeouts.js";
describe("Codex app-server attempt timeouts", () => {
afterEach(() => {
vi.useRealTimers();
});
it("resolves startup timeout with a configurable floor", () => {
expect(resolveCodexStartupTimeoutMs({ timeoutMs: 5 })).toBe(
CODEX_APP_SERVER_STARTUP_TIMEOUT_FLOOR_MS,
);
expect(resolveCodexStartupTimeoutMs({ timeoutMs: 500 })).toBe(500);
expect(resolveCodexStartupTimeoutMs({ timeoutMs: 5, timeoutFloorMs: 250 })).toBe(250);
expect(resolveCodexStartupTimeoutMs({ timeoutMs: Number.NaN })).toBe(
CODEX_APP_SERVER_STARTUP_TIMEOUT_FLOOR_MS,
);
expect(resolveCodexStartupTimeoutMs({ timeoutMs: 500, timeoutFloorMs: Number.NaN })).toBe(500);
expect(resolveCodexStartupTimeoutMs({ timeoutMs: Number.MAX_SAFE_INTEGER })).toBe(
MAX_TIMER_TIMEOUT_MS,
);
expect(
resolveCodexStartupTimeoutMs({
timeoutMs: Number.MAX_SAFE_INTEGER,
timeoutFloorMs: Number.MAX_SAFE_INTEGER,
}),
).toBe(MAX_TIMER_TIMEOUT_MS);
expect(
resolveCodexStartupTimeoutMs({
timeoutMs: Number.NaN,
timeoutFloorMs: Number.NaN,
}),
).toBe(CODEX_APP_SERVER_STARTUP_TIMEOUT_FLOOR_MS);
});
it("normalizes turn idle timeout overrides", () => {
expect(CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS).toBe(5 * 60_000);
expect(CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS).toBeGreaterThan(
CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS,
);
expect(resolveCodexTurnCompletionIdleTimeoutMs(undefined)).toBe(
CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS,
);
expect(resolveCodexTurnCompletionIdleTimeoutMs(Number.POSITIVE_INFINITY)).toBe(
CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS,
);
expect(resolveCodexTurnCompletionIdleTimeoutMs(2.9)).toBe(2);
expect(resolveCodexTurnCompletionIdleTimeoutMs(0)).toBe(1);
expect(resolveCodexTurnCompletionIdleTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe(
MAX_TIMER_TIMEOUT_MS,
);
expect(resolveCodexTurnAssistantCompletionIdleTimeoutMs(undefined)).toBe(
CODEX_TURN_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
);
expect(resolveCodexTurnAssistantCompletionIdleTimeoutMs(Number.NaN)).toBe(
CODEX_TURN_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
);
expect(resolveCodexTurnAssistantCompletionIdleTimeoutMs(9.8)).toBe(9);
expect(resolveCodexTurnAssistantCompletionIdleTimeoutMs(-10)).toBe(1);
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(undefined, 123)).toBe(
CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
);
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(Number.NaN, 123)).toBe(
CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
);
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(undefined, 120_000)).toBe(
CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
);
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(undefined, 6 * 60_000)).toBe(
6 * 60_000,
);
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(undefined, Number.NaN)).toBe(
CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS,
);
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(7.9, 123)).toBe(7);
expect(resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(0, 123)).toBe(1);
expect(
resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs(
Number.MAX_SAFE_INTEGER,
Number.MAX_SAFE_INTEGER,
),
).toBe(MAX_TIMER_TIMEOUT_MS);
expect(resolveCodexTurnTerminalIdleTimeoutMs(undefined)).toBe(
CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS,
);
expect(resolveCodexTurnTerminalIdleTimeoutMs(Number.NEGATIVE_INFINITY)).toBe(
CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS,
);
expect(resolveCodexTurnTerminalIdleTimeoutMs(3.7)).toBe(3);
expect(resolveCodexTurnTerminalIdleTimeoutMs(-1)).toBe(1);
expect(resolveCodexTurnTerminalIdleTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe(
MAX_TIMER_TIMEOUT_MS,
);
});
it("derives the terminal idle timeout from the effective run budget", () => {
const overFloor = CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS + 15 * 60_000;
// A run budget above the 30-minute floor extends the watchdog (the #85242 fix).
expect(resolveCodexTurnTerminalIdleTimeoutMs(undefined, overFloor)).toBe(overFloor);
// A run budget below the floor keeps the 30-minute floor (protection never shortened).
expect(resolveCodexTurnTerminalIdleTimeoutMs(undefined, 10 * 60_000)).toBe(
CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS,
);
// A non-finite budget falls back to the 30-minute default.
expect(resolveCodexTurnTerminalIdleTimeoutMs(undefined, Number.POSITIVE_INFINITY)).toBe(
CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS,
);
expect(resolveCodexTurnTerminalIdleTimeoutMs(undefined, Number.MAX_SAFE_INTEGER)).toBe(
MAX_TIMER_TIMEOUT_MS,
);
// An explicit override still wins even when a run budget is present.
expect(resolveCodexTurnTerminalIdleTimeoutMs(5 * 60_000, overFloor)).toBe(5 * 60_000);
});
it("caps gateway timeout grace", () => {
expect(resolveCodexGatewayTimeoutWithGraceMs(120_000)).toBe(130_000);
expect(resolveCodexGatewayTimeoutWithGraceMs(120_000, 500)).toBe(120_500);
expect(resolveCodexGatewayTimeoutWithGraceMs(Number.MAX_SAFE_INTEGER)).toBe(
MAX_TIMER_TIMEOUT_MS,
);
expect(resolveCodexGatewayTimeoutWithGraceMs(MAX_TIMER_TIMEOUT_MS - 100, 500)).toBe(
MAX_TIMER_TIMEOUT_MS,
);
});
it("returns the startup operation result before timeout", async () => {
await expect(
withCodexStartupTimeout({
timeoutMs: 1_000,
signal: new AbortController().signal,
operation: async () => "ready",
}),
).resolves.toBe("ready");
});
it("waits for startup timeout cleanup before rejecting", async () => {
vi.useFakeTimers();
const events: string[] = [];
const run = withCodexStartupTimeout({
timeoutMs: 10,
signal: new AbortController().signal,
onTimeout: async () => {
events.push("cleanup-start");
await new Promise<void>((resolve) => {
setTimeout(() => {
events.push("cleanup-done");
resolve();
}, 5);
});
},
operation: async () => new Promise<never>(() => {}),
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
await vi.advanceTimersByTimeAsync(10);
expect(events).toEqual(["cleanup-start"]);
await vi.advanceTimersByTimeAsync(5);
await rejected;
expect(events).toEqual(["cleanup-start", "cleanup-done"]);
});
it("rejects startup timeout when aborted before completion", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const run = withCodexStartupTimeout({
timeoutMs: 1_000,
signal: controller.signal,
operation: async () => new Promise<never>(() => {}),
});
const rejected = expect(run).rejects.toThrow("codex app-server startup aborted");
controller.abort();
await rejected;
});
});