@@ -53,14 +53,24 @@ vi.mock("./settings", () => ({
5353 } ) ) ,
5454} ) ) ;
5555
56+ vi . mock ( "node:fs" , async ( importActual ) => {
57+ const actual = await importActual < typeof import ( "node:fs" ) > ( ) ;
58+ return { ...actual , existsSync : vi . fn ( actual . existsSync ) } ;
59+ } ) ;
60+
5661import { CodexAcpAgent } from "./codex-agent" ;
5762
5863describe ( "CodexAcpAgent" , ( ) => {
5964 beforeEach ( ( ) => {
6065 vi . clearAllMocks ( ) ;
6166 } ) ;
6267
63- function createAgent ( overrides : Partial < AgentSideConnection > = { } ) : {
68+ function createAgent (
69+ overrides : Partial < AgentSideConnection > = { } ,
70+ agentOptions ?: {
71+ onStructuredOutput ?: ( output : Record < string , unknown > ) => Promise < void > ;
72+ } ,
73+ ) : {
6474 agent : CodexAcpAgent ;
6575 client : AgentSideConnection & {
6676 extNotification : ReturnType < typeof vi . fn > ;
@@ -80,6 +90,7 @@ describe("CodexAcpAgent", () => {
8090 codexProcessOptions : {
8191 cwd : process . cwd ( ) ,
8292 } ,
93+ onStructuredOutput : agentOptions ?. onStructuredOutput ,
8394 } ) ;
8495 return { agent, client } ;
8596 }
@@ -295,6 +306,128 @@ describe("CodexAcpAgent", () => {
295306 ) . resolves . toEqual ( { stopReason : "end_turn" } ) ;
296307 } ) ;
297308
309+ describe ( "structured output injection" , ( ) => {
310+ const schema = {
311+ type : "object" ,
312+ properties : { answer : { type : "string" } } ,
313+ required : [ "answer" ] ,
314+ } as const ;
315+
316+ beforeEach ( async ( ) => {
317+ // The resolver checks existsSync to find the compiled MCP script.
318+ // In unit tests the dist asset isn't on the walk-up path, so we
319+ // make the first candidate succeed. Nothing in this test actually
320+ // spawns the script — the agent only forwards the path to codex-acp.
321+ const fs = await import ( "node:fs" ) ;
322+ vi . mocked ( fs . existsSync ) . mockReturnValue ( true ) ;
323+ } ) ;
324+
325+ it ( "injects the create_output MCP server and system-prompt note when jsonSchema and callback are present" , async ( ) => {
326+ const { agent } = createAgent ( { } , { onStructuredOutput : vi . fn ( ) } ) ;
327+ mockCodexConnection . newSession . mockResolvedValue ( {
328+ sessionId : "session-1" ,
329+ modes : { currentModeId : "auto" , availableModes : [ ] } ,
330+ configOptions : [ ] ,
331+ } satisfies Partial < NewSessionResponse > ) ;
332+
333+ await agent . newSession ( {
334+ cwd : process . cwd ( ) ,
335+ mcpServers : [ { name : "existing" , command : "echo" , args : [ ] , env : [ ] } ] ,
336+ _meta : { jsonSchema : schema , systemPrompt : "be terse." } ,
337+ } as never ) ;
338+
339+ const forwarded = mockCodexConnection . newSession . mock . calls [ 0 ] [ 0 ] as {
340+ mcpServers : Array < { name : string ; command : string ; env : unknown } > ;
341+ _meta : { systemPrompt : string } ;
342+ } ;
343+
344+ // Existing MCP server is preserved; ours is appended.
345+ expect ( forwarded . mcpServers ) . toHaveLength ( 2 ) ;
346+ expect ( forwarded . mcpServers [ 0 ] . name ) . toBe ( "existing" ) ;
347+ expect ( forwarded . mcpServers [ 1 ] . name ) . toBe ( "posthog_output" ) ;
348+ expect ( forwarded . mcpServers [ 1 ] . command ) . toBe ( process . execPath ) ;
349+
350+ // The schema is forwarded base64-encoded so codex-acp doesn't have
351+ // to escape it through a shell.
352+ const envEntry = (
353+ forwarded . mcpServers [ 1 ] . env as Array < { name : string ; value : string } >
354+ ) . find ( ( e ) => e . name === "POSTHOG_OUTPUT_SCHEMA" ) ;
355+ expect ( envEntry ) . toBeDefined ( ) ;
356+ const decoded = JSON . parse (
357+ Buffer . from ( envEntry ?. value ?? "" , "base64" ) . toString ( "utf-8" ) ,
358+ ) ;
359+ expect ( decoded ) . toEqual ( schema ) ;
360+
361+ // Existing systemPrompt is preserved with the structured-output
362+ // instruction appended (not overwritten).
363+ expect ( forwarded . _meta . systemPrompt . startsWith ( "be terse." ) ) . toBe ( true ) ;
364+ expect ( forwarded . _meta . systemPrompt ) . toContain ( "create_output" ) ;
365+ } ) ;
366+
367+ it ( "is a no-op when jsonSchema is absent" , async ( ) => {
368+ const { agent } = createAgent ( { } , { onStructuredOutput : vi . fn ( ) } ) ;
369+ mockCodexConnection . newSession . mockResolvedValue ( {
370+ sessionId : "session-1" ,
371+ modes : { currentModeId : "auto" , availableModes : [ ] } ,
372+ configOptions : [ ] ,
373+ } satisfies Partial < NewSessionResponse > ) ;
374+
375+ await agent . newSession ( {
376+ cwd : process . cwd ( ) ,
377+ mcpServers : [ ] ,
378+ } as never ) ;
379+
380+ const forwarded = mockCodexConnection . newSession . mock . calls [ 0 ] [ 0 ] as {
381+ mcpServers : unknown [ ] ;
382+ _meta ?: { systemPrompt ?: string } ;
383+ } ;
384+ expect ( forwarded . mcpServers ) . toEqual ( [ ] ) ;
385+ expect ( forwarded . _meta ?. systemPrompt ) . toBeUndefined ( ) ;
386+ } ) ;
387+
388+ it ( "is a no-op when onStructuredOutput callback is not wired" , async ( ) => {
389+ const { agent } = createAgent ( ) ;
390+ mockCodexConnection . newSession . mockResolvedValue ( {
391+ sessionId : "session-1" ,
392+ modes : { currentModeId : "auto" , availableModes : [ ] } ,
393+ configOptions : [ ] ,
394+ } satisfies Partial < NewSessionResponse > ) ;
395+
396+ await agent . newSession ( {
397+ cwd : process . cwd ( ) ,
398+ mcpServers : [ ] ,
399+ _meta : { jsonSchema : schema } ,
400+ } as never ) ;
401+
402+ const forwarded = mockCodexConnection . newSession . mock . calls [ 0 ] [ 0 ] as {
403+ mcpServers : unknown [ ] ;
404+ } ;
405+ expect ( forwarded . mcpServers ) . toEqual ( [ ] ) ;
406+ } ) ;
407+
408+ it ( "also injects on loadSession" , async ( ) => {
409+ const { agent } = createAgent ( { } , { onStructuredOutput : vi . fn ( ) } ) ;
410+ mockCodexConnection . loadSession . mockResolvedValue ( {
411+ modes : { currentModeId : "auto" , availableModes : [ ] } ,
412+ configOptions : [ ] ,
413+ } satisfies Partial < LoadSessionResponse > ) ;
414+
415+ await agent . loadSession ( {
416+ sessionId : "session-1" ,
417+ cwd : process . cwd ( ) ,
418+ mcpServers : [ ] ,
419+ _meta : { jsonSchema : schema } ,
420+ } as never ) ;
421+
422+ const forwarded = mockCodexConnection . loadSession . mock . calls [ 0 ] [ 0 ] as {
423+ mcpServers : Array < { name : string } > ;
424+ } ;
425+ expect ( forwarded . mcpServers . map ( ( s ) => s . name ) ) . toContain (
426+ "posthog_output" ,
427+ ) ;
428+ } ) ;
429+ } ) ;
430+
298431 it ( "broadcasts user prompt as user_message_chunk before delegating to codex-acp" , async ( ) => {
299432 const { agent, client } = createAgent ( ) ;
300433 // Seed an active session so prompt() has the state it expects.
0 commit comments