Skip to content

Commit e5d1fad

Browse files
harjothkharaclaude
andauthored
test(codex): cover thread abandonment after completion-idle timeout (#90027)
Regression coverage for #89974. Confirms that after a turn_completion_idle_timeout, OpenClaw clears the timed-out Codex app-server thread binding and the next turn starts a fresh thread instead of resuming the thread that may hold Codex's generic <turn_aborted> / user-interrupted marker. No runtime behavior changes. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bbfe8cc commit e5d1fad

1 file changed

Lines changed: 54 additions & 1 deletion

File tree

extensions/codex/src/app-server/run-attempt.turn-watches.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { CodexServerNotification } from "./protocol.js";
2020
import { readRecentCodexRateLimits } from "./rate-limit-cache.js";
2121
import {
2222
createParams,
23+
createResumeHarness,
2324
extractRelayIdFromThreadRequest,
2425
createRuntimeDynamicTool,
2526
createStartedThreadHarness,
@@ -34,7 +35,11 @@ import {
3435
turnStartResult,
3536
} from "./run-attempt-test-harness.js";
3637
import { testing } from "./run-attempt.js";
37-
import { resolveCodexAppServerBindingPath } from "./session-binding.js";
38+
import {
39+
readCodexAppServerBinding,
40+
resolveCodexAppServerBindingPath,
41+
writeCodexAppServerBinding,
42+
} from "./session-binding.js";
3843

3944
setupRunAttemptTestHooks();
4045

@@ -2865,6 +2870,54 @@ describe("runCodexAppServerAttempt turn watches", () => {
28652870
);
28662871
});
28672872

2873+
it("clears the thread binding after a completion-idle timeout so the next turn starts fresh", async () => {
2874+
// Regression for openclaw#89974. The "user interrupted the previous turn on
2875+
// purpose" wording is Codex's generic <turn_aborted> rollout marker, written
2876+
// whenever a turn is interrupted (including OpenClaw's own watchdog abort).
2877+
// OpenClaw cannot change that text (turn/interrupt carries no reason); it can
2878+
// only avoid replaying it. This proves a turn_completion_idle_timeout clears
2879+
// the timed-out thread's binding so the next turn starts a fresh thread
2880+
// rather than resuming the thread that may hold that marker.
2881+
vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
2882+
const sessionFile = path.join(tempDir, "session-89974.jsonl");
2883+
const workspaceDir = path.join(tempDir, "workspace-89974");
2884+
await writeCodexAppServerBinding(sessionFile, {
2885+
threadId: "thread-existing",
2886+
cwd: workspaceDir,
2887+
model: "gpt-5.4-codex",
2888+
modelProvider: "openai",
2889+
dynamicToolsFingerprint: "[]",
2890+
});
2891+
2892+
// Turn 1: resume an existing thread, then never deliver turn/completed.
2893+
const firstHarness = createResumeHarness();
2894+
const firstParams = createParams(sessionFile, workspaceDir);
2895+
firstParams.timeoutMs = 200;
2896+
const firstRun = runCodexAppServerAttempt(firstParams, { turnCompletionIdleTimeoutMs: 15 });
2897+
await firstHarness.waitForMethod("turn/start");
2898+
expect(firstHarness.requests.some((entry) => entry.method === "thread/resume")).toBe(true);
2899+
2900+
const firstResult = await firstRun;
2901+
expect(firstResult.timedOut).toBe(true);
2902+
expect(firstResult.promptError).toBe(
2903+
"codex app-server turn idle timed out waiting for turn/completed",
2904+
);
2905+
expect(firstResult.codexAppServerFailure?.kind).toBe("turn_completion_idle_timeout");
2906+
expect(firstResult.codexAppServerFailure?.turnWatchTimeoutKind).toBe("completion");
2907+
// The timed-out thread's binding is gone, so it cannot be resumed.
2908+
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
2909+
2910+
// Turn 2: with no binding, OpenClaw starts a brand-new thread instead of
2911+
// resuming the timed-out one, so Codex's interrupt marker never replays.
2912+
const secondHarness = createStartedThreadHarness();
2913+
const secondRun = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
2914+
await secondHarness.waitForMethod("turn/start");
2915+
expect(secondHarness.requests.some((entry) => entry.method === "thread/start")).toBe(true);
2916+
expect(secondHarness.requests.some((entry) => entry.method === "thread/resume")).toBe(false);
2917+
await secondHarness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
2918+
await secondRun;
2919+
});
2920+
28682921
it("yields a macrotask before processing queued app-server notifications", async () => {
28692922
const harness = createStartedThreadHarness();
28702923
const params = createParams(

0 commit comments

Comments
 (0)