Skip to content

Commit 2a6bada

Browse files
harjothkharaclaude
andcommitted
test(codex): cover thread abandonment after completion-idle timeout
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 cf36b94 commit 2a6bada

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
@@ -19,6 +19,7 @@ import type { CodexServerNotification } from "./protocol.js";
1919
import { readRecentCodexRateLimits } from "./rate-limit-cache.js";
2020
import {
2121
createParams,
22+
createResumeHarness,
2223
extractRelayIdFromThreadRequest,
2324
createRuntimeDynamicTool,
2425
createStartedThreadHarness,
@@ -33,7 +34,11 @@ import {
3334
turnStartResult,
3435
} from "./run-attempt-test-harness.js";
3536
import { testing } from "./run-attempt.js";
36-
import { resolveCodexAppServerBindingPath } from "./session-binding.js";
37+
import {
38+
readCodexAppServerBinding,
39+
resolveCodexAppServerBindingPath,
40+
writeCodexAppServerBinding,
41+
} from "./session-binding.js";
3742

3843
setupRunAttemptTestHooks();
3944

@@ -2864,6 +2869,54 @@ describe("runCodexAppServerAttempt turn watches", () => {
28642869
);
28652870
});
28662871

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

0 commit comments

Comments
 (0)