11import fs from "node:fs" ;
22import os from "node:os" ;
33import path from "node:path" ;
4- import { CURRENT_SESSION_VERSION , SessionManager } from "@mariozechner/pi-coding-agent" ;
4+ import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent" ;
55import { afterEach , describe , expect , it , vi } from "vitest" ;
66import {
77 __testing as replyRunTesting ,
@@ -18,6 +18,7 @@ import {
1818} from "./cli-runner.test-support.js" ;
1919import { executePreparedCliRun } from "./cli-runner/execute.js" ;
2020import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js" ;
21+ import { MAX_CLI_SESSION_HISTORY_MESSAGES } from "./cli-runner/session-history.js" ;
2122import type { PreparedCliRunContext } from "./cli-runner/types.js" ;
2223
2324vi . mock ( "../plugins/hook-runner-global.js" , async ( ) => {
@@ -34,7 +35,9 @@ const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
3435
3536function createSessionFile ( params ?: { history ?: Array < { role : "user" ; content : string } > } ) {
3637 const dir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "openclaw-cli-hooks-" ) ) ;
37- const sessionFile = path . join ( dir , "session.jsonl" ) ;
38+ vi . stubEnv ( "OPENCLAW_STATE_DIR" , dir ) ;
39+ const sessionFile = path . join ( dir , "agents" , "main" , "sessions" , "s1.jsonl" ) ;
40+ fs . mkdirSync ( path . dirname ( sessionFile ) , { recursive : true } ) ;
3841 fs . writeFileSync (
3942 sessionFile ,
4043 `${ JSON . stringify ( {
@@ -46,13 +49,22 @@ function createSessionFile(params?: { history?: Array<{ role: "user"; content: s
4649 } ) } \n`,
4750 "utf-8" ,
4851 ) ;
49- const sessionManager = SessionManager . open ( sessionFile ) ;
50- for ( const entry of params ?. history ?? [ ] ) {
51- sessionManager . appendMessage ( {
52- role : entry . role ,
53- content : entry . content ,
54- timestamp : Date . now ( ) ,
55- } ) ;
52+ for ( const [ index , entry ] of ( params ?. history ?? [ ] ) . entries ( ) ) {
53+ fs . appendFileSync (
54+ sessionFile ,
55+ `${ JSON . stringify ( {
56+ type : "message" ,
57+ id : `msg-${ index } ` ,
58+ parentId : index > 0 ? `msg-${ index - 1 } ` : null ,
59+ timestamp : new Date ( index + 1 ) . toISOString ( ) ,
60+ message : {
61+ role : entry . role ,
62+ content : entry . content ,
63+ timestamp : index + 1 ,
64+ } ,
65+ } ) } \n`,
66+ "utf-8" ,
67+ ) ;
5668 }
5769 return { dir, sessionFile } ;
5870}
@@ -110,6 +122,7 @@ describe("runCliAgent reliability", () => {
110122 afterEach ( ( ) => {
111123 replyRunTesting . resetReplyRunRegistry ( ) ;
112124 mockGetGlobalHookRunner . mockReset ( ) ;
125+ vi . unstubAllEnvs ( ) ;
113126 } ) ;
114127
115128 it ( "fails with timeout when no-output watchdog trips" , async ( ) => {
@@ -193,6 +206,13 @@ describe("runCliAgent reliability", () => {
193206 } ) ;
194207
195208 it ( "rethrows the retry failure when session-expired recovery retry also fails" , async ( ) => {
209+ const hookRunner = {
210+ hasHooks : vi . fn ( ( hookName : string ) => [ "llm_input" , "agent_end" ] . includes ( hookName ) ) ,
211+ runLlmInput : vi . fn ( async ( ) => undefined ) ,
212+ runLlmOutput : vi . fn ( async ( ) => undefined ) ,
213+ runAgentEnd : vi . fn ( async ( ) => undefined ) ,
214+ } ;
215+ mockGetGlobalHookRunner . mockReturnValue ( hookRunner as never ) ;
196216 supervisorSpawnMock . mockClear ( ) ;
197217 supervisorSpawnMock . mockResolvedValueOnce (
198218 createManagedRun ( {
@@ -218,18 +238,50 @@ describe("runCliAgent reliability", () => {
218238 noOutputTimedOut : false ,
219239 } ) ,
220240 ) ;
241+ const { dir, sessionFile } = createSessionFile ( {
242+ history : [ { role : "user" , content : "earlier context" } ] ,
243+ } ) ;
221244
222- await expect (
223- runPreparedCliAgent (
224- buildPreparedContext ( {
225- sessionKey : "agent:main:subagent:retry" ,
226- runId : "run-retry-failure" ,
227- cliSessionId : "thread-123" ,
245+ try {
246+ await expect (
247+ runPreparedCliAgent ( {
248+ ...buildPreparedContext ( {
249+ sessionKey : "agent:main:subagent:retry" ,
250+ runId : "run-retry-failure" ,
251+ cliSessionId : "thread-123" ,
252+ } ) ,
253+ params : {
254+ ...buildPreparedContext ( {
255+ sessionKey : "agent:main:subagent:retry" ,
256+ runId : "run-retry-failure" ,
257+ cliSessionId : "thread-123" ,
258+ } ) . params ,
259+ agentId : "main" ,
260+ sessionFile,
261+ workspaceDir : dir ,
262+ } ,
228263 } ) ,
229- ) ,
230- ) . rejects . toThrow ( "rate limit exceeded" ) ;
264+ ) . rejects . toThrow ( "rate limit exceeded" ) ;
231265
232- expect ( supervisorSpawnMock ) . toHaveBeenCalledTimes ( 2 ) ;
266+ expect ( supervisorSpawnMock ) . toHaveBeenCalledTimes ( 2 ) ;
267+ await vi . waitFor ( ( ) => {
268+ expect ( hookRunner . runLlmInput ) . toHaveBeenCalledTimes ( 1 ) ;
269+ expect ( hookRunner . runAgentEnd ) . toHaveBeenCalledTimes ( 1 ) ;
270+ } ) ;
271+ expect ( hookRunner . runAgentEnd ) . toHaveBeenCalledWith (
272+ expect . objectContaining ( {
273+ success : false ,
274+ error : "rate limit exceeded" ,
275+ messages : [
276+ { role : "user" , content : "earlier context" , timestamp : expect . any ( Number ) } ,
277+ { role : "user" , content : "hi" , timestamp : expect . any ( Number ) } ,
278+ ] ,
279+ } ) ,
280+ expect . any ( Object ) ,
281+ ) ;
282+ } finally {
283+ fs . rmSync ( dir , { recursive : true , force : true } ) ;
284+ }
233285 } ) ;
234286
235287 it ( "returns the assembled CLI prompt in meta for raw trace consumers" , async ( ) => {
@@ -499,6 +551,89 @@ describe("runCliAgent reliability", () => {
499551 expect . any ( Object ) ,
500552 ) ;
501553 } ) ;
554+
555+ it ( "does not emit duplicate llm_input when session-expired recovery succeeds" , async ( ) => {
556+ const hookRunner = {
557+ hasHooks : vi . fn ( ( hookName : string ) =>
558+ [ "llm_input" , "llm_output" , "agent_end" ] . includes ( hookName ) ,
559+ ) ,
560+ runLlmInput : vi . fn ( async ( ) => undefined ) ,
561+ runLlmOutput : vi . fn ( async ( ) => undefined ) ,
562+ runAgentEnd : vi . fn ( async ( ) => undefined ) ,
563+ } ;
564+ mockGetGlobalHookRunner . mockReturnValue ( hookRunner as never ) ;
565+ const { dir, sessionFile } = createSessionFile ( {
566+ history : Array . from ( { length : MAX_CLI_SESSION_HISTORY_MESSAGES + 5 } , ( _ , index ) => ( {
567+ role : "user" as const ,
568+ content : `history-${ index } ` ,
569+ } ) ) ,
570+ } ) ;
571+
572+ supervisorSpawnMock . mockResolvedValueOnce (
573+ createManagedRun ( {
574+ reason : "exit" ,
575+ exitCode : 1 ,
576+ exitSignal : null ,
577+ durationMs : 50 ,
578+ stdout : "" ,
579+ stderr : "session expired" ,
580+ timedOut : false ,
581+ noOutputTimedOut : false ,
582+ } ) ,
583+ ) ;
584+ supervisorSpawnMock . mockResolvedValueOnce (
585+ createManagedRun ( {
586+ reason : "exit" ,
587+ exitCode : 0 ,
588+ exitSignal : null ,
589+ durationMs : 50 ,
590+ stdout : "recovered output" ,
591+ stderr : "" ,
592+ timedOut : false ,
593+ noOutputTimedOut : false ,
594+ } ) ,
595+ ) ;
596+
597+ try {
598+ await expect (
599+ runPreparedCliAgent ( {
600+ ...buildPreparedContext ( {
601+ sessionKey : "agent:main:main" ,
602+ runId : "run-retry-success" ,
603+ cliSessionId : "thread-123" ,
604+ } ) ,
605+ params : {
606+ ...buildPreparedContext ( {
607+ sessionKey : "agent:main:main" ,
608+ runId : "run-retry-success" ,
609+ cliSessionId : "thread-123" ,
610+ } ) . params ,
611+ agentId : "main" ,
612+ sessionFile,
613+ workspaceDir : dir ,
614+ } ,
615+ } ) ,
616+ ) . resolves . toMatchObject ( {
617+ payloads : [ { text : "recovered output" } ] ,
618+ } ) ;
619+
620+ await vi . waitFor ( ( ) => {
621+ expect ( hookRunner . runLlmInput ) . toHaveBeenCalledTimes ( 1 ) ;
622+ expect ( hookRunner . runLlmOutput ) . toHaveBeenCalledTimes ( 1 ) ;
623+ expect ( hookRunner . runAgentEnd ) . toHaveBeenCalledTimes ( 1 ) ;
624+ } ) ;
625+ const llmInputCalls = hookRunner . runLlmInput . mock . calls as unknown as Array < Array < unknown > > ;
626+ const llmInputEvent = llmInputCalls [ 0 ] ?. [ 0 ] as { historyMessages : unknown [ ] } | undefined ;
627+ expect ( llmInputEvent ) . toBeDefined ( ) ;
628+ expect ( llmInputEvent ?. historyMessages ) . toHaveLength ( MAX_CLI_SESSION_HISTORY_MESSAGES ) ;
629+ expect ( llmInputEvent ?. historyMessages [ 0 ] ) . toMatchObject ( {
630+ role : "user" ,
631+ content : `history-5` ,
632+ } ) ;
633+ } finally {
634+ fs . rmSync ( dir , { recursive : true , force : true } ) ;
635+ }
636+ } ) ;
502637} ) ;
503638
504639describe ( "resolveCliNoOutputTimeoutMs" , ( ) => {
0 commit comments