@@ -19,6 +19,7 @@ import type { CodexServerNotification } from "./protocol.js";
1919import { readRecentCodexRateLimits } from "./rate-limit-cache.js" ;
2020import {
2121 createParams ,
22+ createResumeHarness ,
2223 extractRelayIdFromThreadRequest ,
2324 createRuntimeDynamicTool ,
2425 createStartedThreadHarness ,
@@ -33,7 +34,11 @@ import {
3334 turnStartResult ,
3435} from "./run-attempt-test-harness.js" ;
3536import { 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
3843setupRunAttemptTestHooks ( ) ;
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