@@ -226,6 +226,136 @@ describe("CLI attempt execution", () => {
226226 expect ( persisted [ sessionKey ] ?. claudeCliSessionId ) . toBeUndefined ( ) ;
227227 } ) ;
228228
229+ it ( "clears stale Claude CLI session IDs before retrying after session timeout" , async ( ) => {
230+ const sessionKey = "agent:main:subagent:cli-timeout" ;
231+ const homeDir = path . join ( tmpDir , "home-timeout" ) ;
232+ process . env . HOME = homeDir ;
233+ const projectsDir = path . join ( homeDir , ".claude" , "projects" , "demo-workspace" ) ;
234+ await fs . mkdir ( projectsDir , { recursive : true } ) ;
235+ await fs . writeFile (
236+ path . join ( projectsDir , "stale-cli-timeout-session.jsonl" ) ,
237+ `${ JSON . stringify ( { type : "assistant" , message : { role : "assistant" , content : [ { type : "text" , text : "old" } ] } } ) } \n` ,
238+ "utf-8" ,
239+ ) ;
240+ const sessionEntry : SessionEntry = {
241+ sessionId : "session-cli-timeout" ,
242+ updatedAt : Date . now ( ) ,
243+ cliSessionIds : { "claude-cli" : "stale-cli-timeout-session" } ,
244+ } ;
245+ const sessionStore : Record < string , SessionEntry > = { [ sessionKey ] : sessionEntry } ;
246+ await fs . writeFile ( storePath , JSON . stringify ( sessionStore , null , 2 ) , "utf-8" ) ;
247+
248+ runCliAgentMock
249+ . mockRejectedValueOnce (
250+ new FailoverError ( "session timed out" , {
251+ reason : "timeout" ,
252+ provider : "claude-cli" ,
253+ model : "opus" ,
254+ } ) ,
255+ )
256+ . mockResolvedValueOnce ( makeCliResult ( "recovered after timeout" ) ) ;
257+
258+ await runAgentAttempt ( {
259+ providerOverride : "claude-cli" ,
260+ originalProvider : "claude-cli" ,
261+ modelOverride : "opus" ,
262+ cfg : { } as OpenClawConfig ,
263+ sessionEntry,
264+ sessionId : sessionEntry . sessionId ,
265+ sessionKey,
266+ sessionAgentId : "main" ,
267+ sessionFile : path . join ( tmpDir , "session.jsonl" ) ,
268+ workspaceDir : tmpDir ,
269+ body : "retry after timeout" ,
270+ isFallbackRetry : false ,
271+ resolvedThinkLevel : "medium" ,
272+ timeoutMs : 1_000 ,
273+ runId : "run-cli-timeout" ,
274+ opts : { senderIsOwner : false } as Parameters < typeof runAgentAttempt > [ 0 ] [ "opts" ] ,
275+ runContext : { } as Parameters < typeof runAgentAttempt > [ 0 ] [ "runContext" ] ,
276+ spawnedBy : undefined ,
277+ messageChannel : undefined ,
278+ skillsSnapshot : undefined ,
279+ resolvedVerboseLevel : undefined ,
280+ agentDir : tmpDir ,
281+ onAgentEvent : vi . fn ( ) ,
282+ authProfileProvider : "claude-cli" ,
283+ sessionStore,
284+ storePath,
285+ sessionHasHistory : false ,
286+ } ) ;
287+
288+ expect ( runCliAgentMock ) . toHaveBeenCalledTimes ( 2 ) ;
289+ expect ( runCliAgentMock . mock . calls [ 0 ] ?. [ 0 ] ?. cliSessionId ) . toBe ( "stale-cli-timeout-session" ) ;
290+ expect ( runCliAgentMock . mock . calls [ 1 ] ?. [ 0 ] ?. cliSessionId ) . toBeUndefined ( ) ;
291+ expect ( sessionStore [ sessionKey ] ?. cliSessionIds ?. [ "claude-cli" ] ) . toBeUndefined ( ) ;
292+ } ) ;
293+
294+ it ( "clears stale Claude CLI session IDs before retrying after unknown FailoverError" , async ( ) => {
295+ const sessionKey = "agent:main:subagent:cli-unknown" ;
296+ const homeDir = path . join ( tmpDir , "home-unknown" ) ;
297+ process . env . HOME = homeDir ;
298+ const projectsDir2 = path . join ( homeDir , ".claude" , "projects" , "demo-workspace" ) ;
299+ await fs . mkdir ( projectsDir2 , { recursive : true } ) ;
300+ await fs . writeFile (
301+ path . join ( projectsDir2 , "stale-cli-unknown-session.jsonl" ) ,
302+ `${ JSON . stringify ( { type : "assistant" , message : { role : "assistant" , content : [ { type : "text" , text : "old" } ] } } ) } \n` ,
303+ "utf-8" ,
304+ ) ;
305+ const sessionEntry : SessionEntry = {
306+ sessionId : "session-cli-unknown" ,
307+ updatedAt : Date . now ( ) ,
308+ cliSessionIds : { "claude-cli" : "stale-cli-unknown-session" } ,
309+ } ;
310+ const sessionStore : Record < string , SessionEntry > = { [ sessionKey ] : sessionEntry } ;
311+ await fs . writeFile ( storePath , JSON . stringify ( sessionStore , null , 2 ) , "utf-8" ) ;
312+
313+ runCliAgentMock
314+ . mockRejectedValueOnce (
315+ new FailoverError ( "unknown cli failure" , {
316+ reason : "unknown" ,
317+ provider : "claude-cli" ,
318+ model : "opus" ,
319+ } ) ,
320+ )
321+ . mockResolvedValueOnce ( makeCliResult ( "recovered after unknown" ) ) ;
322+
323+ await runAgentAttempt ( {
324+ providerOverride : "claude-cli" ,
325+ originalProvider : "claude-cli" ,
326+ modelOverride : "opus" ,
327+ cfg : { } as OpenClawConfig ,
328+ sessionEntry,
329+ sessionId : sessionEntry . sessionId ,
330+ sessionKey,
331+ sessionAgentId : "main" ,
332+ sessionFile : path . join ( tmpDir , "session.jsonl" ) ,
333+ workspaceDir : tmpDir ,
334+ body : "retry after unknown" ,
335+ isFallbackRetry : false ,
336+ resolvedThinkLevel : "medium" ,
337+ timeoutMs : 1_000 ,
338+ runId : "run-cli-unknown" ,
339+ opts : { senderIsOwner : false } as Parameters < typeof runAgentAttempt > [ 0 ] [ "opts" ] ,
340+ runContext : { } as Parameters < typeof runAgentAttempt > [ 0 ] [ "runContext" ] ,
341+ spawnedBy : undefined ,
342+ messageChannel : undefined ,
343+ skillsSnapshot : undefined ,
344+ resolvedVerboseLevel : undefined ,
345+ agentDir : tmpDir ,
346+ onAgentEvent : vi . fn ( ) ,
347+ authProfileProvider : "claude-cli" ,
348+ sessionStore,
349+ storePath,
350+ sessionHasHistory : false ,
351+ } ) ;
352+
353+ expect ( runCliAgentMock ) . toHaveBeenCalledTimes ( 2 ) ;
354+ expect ( runCliAgentMock . mock . calls [ 0 ] ?. [ 0 ] ?. cliSessionId ) . toBe ( "stale-cli-unknown-session" ) ;
355+ expect ( runCliAgentMock . mock . calls [ 1 ] ?. [ 0 ] ?. cliSessionId ) . toBeUndefined ( ) ;
356+ expect ( sessionStore [ sessionKey ] ?. cliSessionIds ?. [ "claude-cli" ] ) . toBeUndefined ( ) ;
357+ } ) ;
358+
229359 it ( "does not pass --resume when the stored Claude CLI transcript is missing" , async ( ) => {
230360 const sessionKey = "agent:main:direct:claude-missing-transcript" ;
231361 const homeDir = path . join ( tmpDir , "home" ) ;
0 commit comments