11// @vitest -environment node
2- import { describe , expect , it , vi } from "vitest" ;
2+ import { beforeEach , describe , expect , it , vi } from "vitest" ;
33
44// Mock node:fs so readFileSync never touches disk (gemini reads system prompt file, codex reads auth)
55vi . mock ( "node:fs" , ( ) => ( {
@@ -8,6 +8,12 @@ vi.mock("node:fs", () => ({
88 } ) ,
99} ) ) ;
1010
11+ vi . mock ( "node:child_process" , ( ) => ( {
12+ execSync : vi . fn ( ) . mockImplementation ( ( ) => {
13+ throw new Error ( "missing" ) ;
14+ } ) ,
15+ } ) ) ;
16+
1117// Mock logger to suppress output
1218vi . mock ( "../src/logger.js" , ( ) => ( {
1319 createLogger : ( ) => ( {
@@ -62,6 +68,18 @@ import {
6268import { getAvailableProviders , getProvider , registerProvider } from "../src/providers/registry.js" ;
6369import type { AgentProvider } from "../src/providers/types.js" ;
6470
71+ beforeEach ( async ( ) => {
72+ const fsModule = await import ( "node:fs" ) ;
73+ vi . mocked ( fsModule . readFileSync ) . mockImplementation ( ( ) => {
74+ throw new Error ( "ENOENT" ) ;
75+ } ) ;
76+
77+ const childProcess = await import ( "node:child_process" ) ;
78+ vi . mocked ( childProcess . execSync ) . mockImplementation ( ( ) => {
79+ throw new Error ( "missing" ) ;
80+ } ) ;
81+ } ) ;
82+
6583// ---------------------------------------------------------------------------
6684// mapSDKMessage — rate_limit_event
6785// ---------------------------------------------------------------------------
@@ -591,6 +609,8 @@ describe("codexProvider.execute — handle shape", () => {
591609describe ( "codexProvider.execute — thread selection" , ( ) => {
592610 it ( "calls startThread when resume is false or absent" , async ( ) => {
593611 const { Codex } = await import ( "@openai/codex-sdk" ) ;
612+ const childProcess = await import ( "node:child_process" ) ;
613+ vi . mocked ( childProcess . execSync ) . mockReturnValueOnce ( "/opt/homebrew/bin/codex\n" as any ) ;
594614 const startThreadSpy = vi . fn ( ) . mockReturnValue ( {
595615 runStreamed : vi . fn ( ) . mockResolvedValue ( { events : ( async function * ( ) { } ) ( ) } ) ,
596616 } ) ;
@@ -603,10 +623,15 @@ describe("codexProvider.execute — thread selection", () => {
603623 ) ;
604624 await codexProvider . execute ( { sessionId : "s1" , cwd : "/tmp" , env : { } , taskContext : "ctx" } ) ;
605625 expect ( startThreadSpy ) . toHaveBeenCalledOnce ( ) ;
626+ expect ( vi . mocked ( Codex ) ) . toHaveBeenCalledWith ( expect . objectContaining ( { codexPathOverride : "/opt/homebrew/bin/codex" } ) ) ;
606627 } ) ;
607628
608629 it ( "calls resumeThread when resume is true" , async ( ) => {
609630 const { Codex } = await import ( "@openai/codex-sdk" ) ;
631+ const childProcess = await import ( "node:child_process" ) ;
632+ vi . mocked ( childProcess . execSync ) . mockImplementationOnce ( ( ) => {
633+ throw new Error ( "missing" ) ;
634+ } ) ;
610635 const resumeThreadSpy = vi . fn ( ) . mockReturnValue ( {
611636 runStreamed : vi . fn ( ) . mockResolvedValue ( { events : ( async function * ( ) { } ) ( ) } ) ,
612637 } ) ;
@@ -617,8 +642,63 @@ describe("codexProvider.execute — thread selection", () => {
617642 resumeThread : resumeThreadSpy ,
618643 } ) as any ,
619644 ) ;
620- await codexProvider . execute ( { sessionId : "sess-77" , cwd : "/tmp" , env : { } , taskContext : "ctx" , resume : true } ) ;
621- expect ( resumeThreadSpy ) . toHaveBeenCalledWith ( "sess-77" , expect . any ( Object ) ) ;
645+ await codexProvider . execute ( {
646+ sessionId : "sess-77" ,
647+ resumeToken : "codex-thread-1" ,
648+ cwd : "/tmp" ,
649+ env : { } ,
650+ taskContext : "ctx" ,
651+ resume : true ,
652+ } ) ;
653+ expect ( resumeThreadSpy ) . toHaveBeenCalledWith ( "codex-thread-1" , expect . any ( Object ) ) ;
654+ } ) ;
655+
656+ it ( "omits explicit model for ChatGPT-backed Codex sessions" , async ( ) => {
657+ const { Codex } = await import ( "@openai/codex-sdk" ) ;
658+ const fsModule = await import ( "node:fs" ) ;
659+ const childProcess = await import ( "node:child_process" ) ;
660+ vi . mocked ( childProcess . execSync ) . mockReturnValueOnce ( "/opt/homebrew/bin/codex\n" as any ) ;
661+ vi . mocked ( fsModule . readFileSync ) . mockReturnValue ( JSON . stringify ( { tokens : { access_token : "chatgpt-token" } } ) as any ) ;
662+ const startThreadSpy = vi . fn ( ) . mockReturnValue ( {
663+ runStreamed : vi . fn ( ) . mockResolvedValue ( { events : ( async function * ( ) { } ) ( ) } ) ,
664+ } ) ;
665+ vi . mocked ( Codex ) . mockImplementationOnce (
666+ ( ) =>
667+ ( {
668+ startThread : startThreadSpy ,
669+ resumeThread : vi . fn ( ) ,
670+ } ) as any ,
671+ ) ;
672+
673+ await codexProvider . execute ( { sessionId : "s1" , cwd : "/tmp" , env : { } , taskContext : "ctx" , model : "o3" } ) ;
674+
675+ expect ( startThreadSpy ) . toHaveBeenCalledWith ( expect . objectContaining ( { model : undefined } ) ) ;
676+ } ) ;
677+
678+ it ( "keeps explicit model when using API-key-backed Codex sessions" , async ( ) => {
679+ const { Codex } = await import ( "@openai/codex-sdk" ) ;
680+ const childProcess = await import ( "node:child_process" ) ;
681+ vi . mocked ( childProcess . execSync ) . mockReturnValueOnce ( "/opt/homebrew/bin/codex\n" as any ) ;
682+ const startThreadSpy = vi . fn ( ) . mockReturnValue ( {
683+ runStreamed : vi . fn ( ) . mockResolvedValue ( { events : ( async function * ( ) { } ) ( ) } ) ,
684+ } ) ;
685+ vi . mocked ( Codex ) . mockImplementationOnce (
686+ ( ) =>
687+ ( {
688+ startThread : startThreadSpy ,
689+ resumeThread : vi . fn ( ) ,
690+ } ) as any ,
691+ ) ;
692+
693+ await codexProvider . execute ( {
694+ sessionId : "s1" ,
695+ cwd : "/tmp" ,
696+ env : { OPENAI_API_KEY : "sk-test" } ,
697+ taskContext : "ctx" ,
698+ model : "o3" ,
699+ } ) ;
700+
701+ expect ( startThreadSpy ) . toHaveBeenCalledWith ( expect . objectContaining ( { model : "o3" } ) ) ;
622702 } ) ;
623703
624704 it ( "events yields mapped AgentEvents from SDK thread events" , async ( ) => {
@@ -646,6 +726,29 @@ describe("codexProvider.execute — thread selection", () => {
646726 expect ( events [ 1 ] ) . toEqual ( { type : "block.done" , block : { type : "text" , text : "codex message" } } ) ;
647727 } ) ;
648728
729+ it ( "captures the Codex thread id as a resume token from thread.started" , async ( ) => {
730+ const { Codex } = await import ( "@openai/codex-sdk" ) ;
731+ vi . mocked ( Codex ) . mockImplementationOnce (
732+ ( ) =>
733+ ( {
734+ startThread : vi . fn ( ) . mockReturnValue ( {
735+ runStreamed : vi . fn ( ) . mockResolvedValue ( {
736+ events : ( async function * ( ) {
737+ yield { type : "thread.started" , thread_id : "thread-123" } as any ;
738+ yield { type : "turn.completed" , usage : { } } as any ;
739+ } ) ( ) ,
740+ } ) ,
741+ } ) ,
742+ resumeThread : vi . fn ( ) ,
743+ } ) as any ,
744+ ) ;
745+ const handle = await codexProvider . execute ( { sessionId : "s1" , cwd : "/tmp" , env : { } , taskContext : "ctx" } ) ;
746+ for await ( const _ev of handle . events ) {
747+ // Drain the stream so thread.started is observed.
748+ }
749+ expect ( handle . getResumeToken ?.( ) ) . toBe ( "thread-123" ) ;
750+ } ) ;
751+
649752 it ( "send() throws not-implemented error" , async ( ) => {
650753 const { Codex } = await import ( "@openai/codex-sdk" ) ;
651754 const runStreamedSpy = vi . fn ( ) . mockResolvedValue ( { events : ( async function * ( ) { } ) ( ) } ) ;
0 commit comments