@@ -14,6 +14,9 @@ import { sandboxPolicyFor } from "./session-config";
1414
1515// Required-field invariants the native codex app-server enforces on each request.
1616const REQUIRED_FIELDS : Record < string , string [ ] > = {
17+ "thread/goal/clear" : [ "threadId" ] ,
18+ "thread/goal/get" : [ "threadId" ] ,
19+ "thread/goal/set" : [ "threadId" ] ,
1720 "turn/interrupt" : [ "threadId" , "turnId" ] ,
1821 "turn/steer" : [ "threadId" , "input" , "expectedTurnId" ] ,
1922} ;
@@ -43,7 +46,12 @@ function makeStubRpc(responses: Record<string, unknown>) {
4346 message : `Invalid request: missing field \`${ missing } \`` ,
4447 } ;
4548 }
46- return ( responses [ method ] ?? { } ) as T ;
49+ const response = responses [ method ] ;
50+ return (
51+ typeof response === "function"
52+ ? await response ( params )
53+ : ( response ?? { } )
54+ ) as T ;
4755 } ,
4856 notify ( ) { } ,
4957 async close ( ) { } ,
@@ -295,6 +303,274 @@ describe("CodexAppServerAgent", () => {
295303 ) . toHaveLength ( 1 ) ;
296304 } ) ;
297305
306+ it . each ( [
307+ {
308+ label : "reads an empty goal" ,
309+ prompt : "/goal" ,
310+ method : "thread/goal/get" ,
311+ response : { goal : null } ,
312+ expectedParams : { threadId : "thr_1" } ,
313+ expectedText : "No goal set. Usage: `/goal <objective>`" ,
314+ expectedGoal : undefined ,
315+ } ,
316+ {
317+ label : "reads an active goal" ,
318+ prompt : "/goal" ,
319+ method : "thread/goal/get" ,
320+ response : { goal : { objective : "Ship the fix" , status : "active" } } ,
321+ expectedParams : { threadId : "thr_1" } ,
322+ expectedText : "Goal active: Ship the fix" ,
323+ expectedGoal : undefined ,
324+ } ,
325+ {
326+ label : "sets a goal" ,
327+ prompt : "/goal Ship the fix" ,
328+ method : "thread/goal/set" ,
329+ response : { goal : { objective : "Ship the fix" , status : "active" } } ,
330+ expectedParams : { threadId : "thr_1" , objective : "Ship the fix" } ,
331+ expectedText : "Goal set: Ship the fix" ,
332+ expectedGoal : { objective : "Ship the fix" , status : "active" } ,
333+ } ,
334+ {
335+ label : "clears a goal" ,
336+ prompt : "/goal clear" ,
337+ method : "thread/goal/clear" ,
338+ response : { cleared : true } ,
339+ expectedParams : { threadId : "thr_1" } ,
340+ expectedText : "Goal cleared." ,
341+ expectedGoal : null ,
342+ } ,
343+ {
344+ label : "pauses a goal" ,
345+ prompt : "/goal pause" ,
346+ method : "thread/goal/set" ,
347+ response : { goal : { objective : "Ship the fix" , status : "paused" } } ,
348+ expectedParams : { threadId : "thr_1" , status : "paused" } ,
349+ expectedText : "Goal paused: Ship the fix" ,
350+ expectedGoal : { objective : "Ship the fix" , status : "paused" } ,
351+ } ,
352+ {
353+ label : "resumes a goal" ,
354+ prompt : "/goal resume" ,
355+ method : "thread/goal/set" ,
356+ response : { goal : { objective : "Ship the fix" , status : "active" } } ,
357+ expectedParams : { threadId : "thr_1" , status : "active" } ,
358+ expectedText : "Goal resumed: Ship the fix" ,
359+ expectedGoal : { objective : "Ship the fix" , status : "active" } ,
360+ } ,
361+ ] ) ( "$label without starting a model turn" , async ( testCase ) => {
362+ const stub = makeStubRpc ( {
363+ "thread/start" : { thread : { id : "thr_1" } } ,
364+ [ testCase . method ] : testCase . response ,
365+ } ) ;
366+ const { client, sessionUpdates, extNotifications } = makeFakeClient ( ) ;
367+ const agent = new CodexAppServerAgent ( client , {
368+ processOptions : { binaryPath : "/bundle/codex" } ,
369+ rpcFactory : stub . factory ,
370+ } ) ;
371+
372+ await agent . newSession ( { cwd : "/repo" } as unknown as NewSessionRequest ) ;
373+ const result = await agent . prompt ( {
374+ sessionId : "thr_1" ,
375+ prompt : [ { type : "text" , text : testCase . prompt } ] ,
376+ } as unknown as PromptRequest ) ;
377+
378+ expect ( result . stopReason ) . toBe ( "end_turn" ) ;
379+ expect ( stub . requests ) . toContainEqual ( {
380+ method : testCase . method ,
381+ params : testCase . expectedParams ,
382+ } ) ;
383+ expect (
384+ stub . requests . some ( ( request ) => request . method === "turn/start" ) ,
385+ ) . toBe ( false ) ;
386+ expect ( sessionUpdates ) . toContainEqual ( {
387+ sessionId : "thr_1" ,
388+ update : {
389+ sessionUpdate : "agent_message_chunk" ,
390+ content : { type : "text" , text : testCase . expectedText } ,
391+ } ,
392+ } ) ;
393+ if ( testCase . expectedGoal !== undefined ) {
394+ expect ( extNotifications ) . toContainEqual ( {
395+ method : "_posthog/codex_goal" ,
396+ params : { goal : testCase . expectedGoal } ,
397+ } ) ;
398+ }
399+ } ) ;
400+
401+ it ( "handles a goal command wrapped in hidden cold-resume context" , async ( ) => {
402+ const stub = makeStubRpc ( {
403+ "thread/start" : { thread : { id : "thr_1" } } ,
404+ "thread/goal/get" : {
405+ goal : { objective : "Ship the fix" , status : "paused" } ,
406+ } ,
407+ } ) ;
408+ const { client, sessionUpdates } = makeFakeClient ( ) ;
409+ const agent = new CodexAppServerAgent ( client , {
410+ processOptions : { binaryPath : "/bundle/codex" } ,
411+ rpcFactory : stub . factory ,
412+ } ) ;
413+ await agent . newSession ( { cwd : "/repo" } as unknown as NewSessionRequest ) ;
414+
415+ await agent . prompt ( {
416+ sessionId : "thr_1" ,
417+ prompt : [
418+ {
419+ type : "text" ,
420+ text : "Previous conversation context" ,
421+ _meta : { ui : { hidden : true } } ,
422+ } ,
423+ { type : "text" , text : "/goal" } ,
424+ {
425+ type : "text" ,
426+ text : "Respond to the user above" ,
427+ _meta : { ui : { hidden : true } } ,
428+ } ,
429+ ] ,
430+ } as unknown as PromptRequest ) ;
431+
432+ expect ( stub . requests ) . toContainEqual ( {
433+ method : "thread/goal/get" ,
434+ params : { threadId : "thr_1" } ,
435+ } ) ;
436+ expect (
437+ stub . requests . some ( ( request ) => request . method === "turn/start" ) ,
438+ ) . toBe ( false ) ;
439+ expect ( sessionUpdates ) . toContainEqual ( {
440+ sessionId : "thr_1" ,
441+ update : {
442+ sessionUpdate : "user_message_chunk" ,
443+ content : { type : "text" , text : "/goal" } ,
444+ } ,
445+ } ) ;
446+ } ) ;
447+
448+ it ( "restores a persisted goal when starting a replacement thread" , async ( ) => {
449+ const restoredGoal = { objective : "Ship the fix" , status : "paused" } ;
450+ const stub = makeStubRpc ( {
451+ "thread/start" : { thread : { id : "thr_1" } } ,
452+ "thread/goal/set" : { goal : restoredGoal } ,
453+ } ) ;
454+ const { client, extNotifications } = makeFakeClient ( ) ;
455+ const agent = new CodexAppServerAgent ( client , {
456+ processOptions : { binaryPath : "/bundle/codex" } ,
457+ rpcFactory : stub . factory ,
458+ } ) ;
459+
460+ await agent . newSession ( {
461+ cwd : "/repo" ,
462+ _meta : { nativeGoal : restoredGoal } ,
463+ } as unknown as NewSessionRequest ) ;
464+
465+ expect ( stub . requests ) . toContainEqual ( {
466+ method : "thread/goal/set" ,
467+ params : { threadId : "thr_1" , ...restoredGoal } ,
468+ } ) ;
469+ expect ( extNotifications ) . toContainEqual ( {
470+ method : "_posthog/codex_goal" ,
471+ params : { goal : restoredGoal } ,
472+ } ) ;
473+ } ) ;
474+
475+ it ( "interrupts a native goal turn that was already queued when paused" , async ( ) => {
476+ const stub = makeStubRpc ( {
477+ "thread/start" : { thread : { id : "thr_1" } } ,
478+ "thread/goal/set" : {
479+ goal : { objective : "Ship the fix" , status : "paused" } ,
480+ } ,
481+ } ) ;
482+ const { client } = makeFakeClient ( ) ;
483+ const agent = new CodexAppServerAgent ( client , {
484+ processOptions : { binaryPath : "/bundle/codex" } ,
485+ rpcFactory : stub . factory ,
486+ } ) ;
487+ await agent . newSession ( { cwd : "/repo" } as unknown as NewSessionRequest ) ;
488+
489+ await agent . prompt ( {
490+ sessionId : "thr_1" ,
491+ prompt : [ { type : "text" , text : "/goal pause" } ] ,
492+ } as unknown as PromptRequest ) ;
493+ stub . emit ( "turn/started" , { turn : { id : "goal_tick_1" } } ) ;
494+ await Promise . resolve ( ) ;
495+
496+ expect ( stub . requests ) . toContainEqual ( {
497+ method : "turn/interrupt" ,
498+ params : { threadId : "thr_1" , turnId : "goal_tick_1" } ,
499+ } ) ;
500+ } ) ;
501+
502+ it ( "interrupts a native goal turn that started before it was paused" , async ( ) => {
503+ const stub = makeStubRpc ( {
504+ "thread/start" : { thread : { id : "thr_1" } } ,
505+ "thread/goal/set" : {
506+ goal : { objective : "Ship the fix" , status : "paused" } ,
507+ } ,
508+ } ) ;
509+ const { client } = makeFakeClient ( ) ;
510+ const agent = new CodexAppServerAgent ( client , {
511+ processOptions : { binaryPath : "/bundle/codex" } ,
512+ rpcFactory : stub . factory ,
513+ } ) ;
514+ await agent . newSession ( { cwd : "/repo" } as unknown as NewSessionRequest ) ;
515+ stub . emit ( "turn/started" , { turn : { id : "goal_tick_1" } } ) ;
516+
517+ await agent . prompt ( {
518+ sessionId : "thr_1" ,
519+ prompt : [ { type : "text" , text : "/goal pause" } ] ,
520+ } as unknown as PromptRequest ) ;
521+
522+ expect ( stub . requests ) . toContainEqual ( {
523+ method : "turn/interrupt" ,
524+ params : { threadId : "thr_1" , turnId : "goal_tick_1" } ,
525+ } ) ;
526+ } ) ;
527+
528+ it ( "retries queued goal cancellation after an interrupt failure" , async ( ) => {
529+ let interruptAttempts = 0 ;
530+ const stub = makeStubRpc ( {
531+ "thread/start" : { thread : { id : "thr_1" } } ,
532+ "thread/goal/set" : {
533+ goal : { objective : "Ship the fix" , status : "paused" } ,
534+ } ,
535+ "turn/interrupt" : ( ) => {
536+ interruptAttempts ++ ;
537+ if ( interruptAttempts === 1 ) {
538+ throw new Error ( "interrupt failed" ) ;
539+ }
540+ return { } ;
541+ } ,
542+ } ) ;
543+ const { client } = makeFakeClient ( ) ;
544+ const agent = new CodexAppServerAgent ( client , {
545+ processOptions : { binaryPath : "/bundle/codex" } ,
546+ rpcFactory : stub . factory ,
547+ } ) ;
548+ await agent . newSession ( { cwd : "/repo" } as unknown as NewSessionRequest ) ;
549+
550+ await agent . prompt ( {
551+ sessionId : "thr_1" ,
552+ prompt : [ { type : "text" , text : "/goal pause" } ] ,
553+ } as unknown as PromptRequest ) ;
554+ stub . emit ( "turn/started" , { turn : { id : "goal_tick_1" } } ) ;
555+ await Promise . resolve ( ) ;
556+ await Promise . resolve ( ) ;
557+ stub . emit ( "turn/started" , { turn : { id : "goal_tick_2" } } ) ;
558+ await Promise . resolve ( ) ;
559+
560+ expect (
561+ stub . requests . filter ( ( request ) => request . method === "turn/interrupt" ) ,
562+ ) . toEqual ( [
563+ {
564+ method : "turn/interrupt" ,
565+ params : { threadId : "thr_1" , turnId : "goal_tick_1" } ,
566+ } ,
567+ {
568+ method : "turn/interrupt" ,
569+ params : { threadId : "thr_1" , turnId : "goal_tick_2" } ,
570+ } ,
571+ ] ) ;
572+ } ) ;
573+
298574 it ( "includes buffered command output when completion omits aggregatedOutput" , async ( ) => {
299575 const stub = makeStubRpc ( {
300576 initialize : { } ,
@@ -1713,6 +1989,7 @@ describe("CodexAppServerAgent", () => {
17131989 {
17141990 skills : [
17151991 { name : "deploy" , description : "Deploy" , enabled : true } ,
1992+ { name : "goal" , description : "Duplicate" , enabled : true } ,
17161993 { name : "danger" , description : "Disabled" , enabled : false } ,
17171994 ] ,
17181995 } ,
@@ -1731,7 +2008,14 @@ describe("CodexAppServerAgent", () => {
17312008 ( u : any ) => u . update ?. sessionUpdate === "available_commands_update" ,
17322009 ) as any
17332010 ) ?. update ?. availableCommands ;
1734- expect ( cmds . map ( ( c : { name : string } ) => c . name ) ) . toEqual ( [ "deploy" ] ) ;
2011+ expect ( cmds ) . toEqual ( [
2012+ {
2013+ name : "goal" ,
2014+ description : "Set or view the goal for a long-running task" ,
2015+ input : { hint : "[<objective>|clear|pause|resume]" } ,
2016+ } ,
2017+ { name : "deploy" , description : "Deploy" } ,
2018+ ] ) ;
17352019 } ) ;
17362020
17372021 it ( "emits _posthog/sdk_session when a taskRunId is present" , async ( ) => {
0 commit comments