@@ -20,6 +20,7 @@ import type { CodexServerNotification } from "./protocol.js";
2020import { readRecentCodexRateLimits } from "./rate-limit-cache.js" ;
2121import {
2222 createParams ,
23+ createResumeHarness ,
2324 extractRelayIdFromThreadRequest ,
2425 createRuntimeDynamicTool ,
2526 createStartedThreadHarness ,
@@ -34,7 +35,11 @@ import {
3435 turnStartResult ,
3536} from "./run-attempt-test-harness.js" ;
3637import { 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
3944setupRunAttemptTestHooks ( ) ;
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