@@ -6,11 +6,16 @@ import {
66 type CodexMockTestFixture ,
77} from "../acp-test-utils" ;
88import type { SessionState } from "../../CodexAcpServer" ;
9+ import { ACPSessionConnection } from "../../ACPSessionConnection" ;
10+ import { CodexSubagentEventRouter } from "../../subagents/CodexSubagentEventRouter" ;
11+ import type { ServerNotification } from "../../app-server" ;
912import { CodexCommands } from "../../CodexCommands" ;
1013import { logger } from "../../Logger" ;
1114import type { Turn , TurnCompletedNotification } from "../../app-server/v2" ;
1215import { AGENT_FILE_CHANGE_REPORT_MAX_DIFF_BYTES } from "../../AgentFileChangeReport" ;
1316
17+ const CHILD_THREAD_ID = "child-thread" ;
18+
1419function createTurn ( id : string , status : Turn [ "status" ] ) : Turn {
1520 return {
1621 id,
@@ -61,6 +66,60 @@ const FILE_CHANGE_REPORT_CLIENT_CAPABILITIES: acp.ClientCapabilities = {
6166 } ,
6267} ;
6368
69+ function modifiedFileDiff ( pathname : string ) : string {
70+ return `diff --git a/${ pathname } b/${ pathname } \n--- a/${ pathname } \n+++ b/${ pathname } \n@@ -1 +1 @@\n-old\n+new\n` ;
71+ }
72+
73+ function turnDiffUpdated ( threadId : string , turnId : string , diff : string ) : ServerNotification {
74+ return { method : "turn/diff/updated" , params : { threadId, turnId, diff} } ;
75+ }
76+
77+ /** Notifications of a root turn that spawns a native subagent, as Codex reports them. */
78+ function spawnChild ( rootSessionId : string ) : ServerNotification [ ] {
79+ return [
80+ {
81+ method : "item/started" ,
82+ params : {
83+ threadId : rootSessionId ,
84+ turnId : "main-turn" ,
85+ startedAtMs : 0 ,
86+ item : {
87+ type : "collabAgentToolCall" ,
88+ id : "spawn-child" ,
89+ tool : "spawnAgent" ,
90+ status : "inProgress" ,
91+ senderThreadId : rootSessionId ,
92+ receiverThreadIds : [ CHILD_THREAD_ID ] ,
93+ prompt : "Edit the docs." ,
94+ model : null ,
95+ reasoningEffort : null ,
96+ agentsStates : { [ CHILD_THREAD_ID ] : { status : "running" , message : null } } ,
97+ } ,
98+ } ,
99+ } ,
100+ {
101+ method : "item/started" ,
102+ params : {
103+ threadId : rootSessionId ,
104+ turnId : "main-turn" ,
105+ startedAtMs : 0 ,
106+ item : {
107+ type : "subAgentActivity" ,
108+ id : "activity-child" ,
109+ kind : "started" ,
110+ agentThreadId : CHILD_THREAD_ID ,
111+ agentPath : "/root/docs_writer" ,
112+ } ,
113+ } ,
114+ } ,
115+ { method : "turn/started" , params : { threadId : CHILD_THREAD_ID , turn : createTurn ( "child-turn" , "inProgress" ) } } ,
116+ ] ;
117+ }
118+
119+ function childTurnCompleted ( ) : ServerNotification {
120+ return { method : "turn/completed" , params : { threadId : CHILD_THREAD_ID , turn : createTurn ( "child-turn" , "completed" ) } } ;
121+ }
122+
64123async function setupMainPrompt ( negotiateCapability = true ) : Promise < {
65124 fixture : CodexMockTestFixture ;
66125 sessionState : SessionState ;
@@ -87,6 +146,32 @@ async function setupMainPrompt(negotiateCapability = true): Promise<{
87146 return { fixture, sessionState, turnStart, awaitTurnCompleted} ;
88147}
89148
149+ async function setupNativeSubagentPrompt ( ) : Promise < {
150+ fixture : CodexMockTestFixture ;
151+ sessionState : SessionState ;
152+ awaitTurnCompleted : ReturnType < typeof vi . spyOn > ;
153+ } > {
154+ const fixture = createCodexMockTestFixture ( ) ;
155+ await fixture . getCodexAcpAgent ( ) . initialize ( {
156+ protocolVersion : acp . PROTOCOL_VERSION ,
157+ clientCapabilities : {
158+ _meta : { jetbrains : { air : { version : 1 , capabilities : [ "agentFileChangeReport" , "nativeSubagentSessions" ] } } } ,
159+ } ,
160+ } ) ;
161+ const sessionState = createTestSessionState ( { cwd : "/workspace" } ) ;
162+ sessionState . subagents = new CodexSubagentEventRouter (
163+ sessionState . sessionId ,
164+ true ,
165+ new ACPSessionConnection ( fixture . getAcpConnection ( ) , sessionState . sessionId ) ,
166+ ( ) => { } ,
167+ ) ;
168+ vi . spyOn ( fixture . getCodexAcpAgent ( ) , "getSessionState" ) . mockReturnValue ( sessionState ) ;
169+ vi . spyOn ( fixture . getCodexAppServerClient ( ) , "turnStart" )
170+ . mockResolvedValue ( { turn : createTurn ( "main-turn" , "inProgress" ) } ) ;
171+ const awaitTurnCompleted = vi . spyOn ( fixture . getCodexAppServerClient ( ) , "awaitTurnCompleted" ) ;
172+ return { fixture, sessionState, awaitTurnCompleted} ;
173+ }
174+
90175describe ( "agent file-change report lifecycle" , ( ) => {
91176 beforeEach ( ( ) => {
92177 vi . clearAllMocks ( ) ;
@@ -321,4 +406,85 @@ describe("agent file-change report lifecycle", () => {
321406 } } } } ,
322407 } ) ;
323408 } ) ;
409+
410+ describe ( "with native subagents" , ( ) => {
411+ it ( "includes the files a subagent changed during the root turn" , async ( ) => {
412+ const { fixture, sessionState, awaitTurnCompleted} = await setupNativeSubagentPrompt ( ) ;
413+ awaitTurnCompleted . mockImplementationOnce ( async ( ) : Promise < TurnCompletedNotification > => {
414+ for ( const notification of [
415+ turnDiffUpdated ( sessionState . sessionId , "main-turn" , modifiedFileDiff ( "README.md" ) ) ,
416+ ...spawnChild ( sessionState . sessionId ) ,
417+ turnDiffUpdated ( CHILD_THREAD_ID , "child-turn" , modifiedFileDiff ( "README.md" )
418+ + modifiedFileDiff ( "community/README.md" ) ) ,
419+ childTurnCompleted ( ) ,
420+ ] ) {
421+ fixture . sendServerNotification ( notification ) ;
422+ }
423+ return { threadId : sessionState . sessionId , turn : createTurn ( "main-turn" , "completed" ) } ;
424+ } ) ;
425+
426+ await expect ( fixture . getCodexAcpAgent ( ) . prompt (
427+ promptWithFileChangeReport ( sessionState . sessionId , "request-subagent" ) ,
428+ ) ) . resolves . toMatchObject ( { stopReason : "end_turn" } ) ;
429+
430+ expect ( reportedUpdates ( fixture ) [ 0 ] ) . toMatchObject ( {
431+ _meta : { jetbrains : { air : { agentFileChangeReport : {
432+ status : "reported" ,
433+ paths : [ "/workspace/README.md" , "/workspace/community/README.md" ] ,
434+ } } } } ,
435+ } ) ;
436+ } ) ;
437+
438+ it ( "includes the files of a subagent that finishes after the root turn ends" , async ( ) => {
439+ const { fixture, sessionState, awaitTurnCompleted} = await setupNativeSubagentPrompt ( ) ;
440+ awaitTurnCompleted . mockImplementationOnce ( async ( ) : Promise < TurnCompletedNotification > => {
441+ const completed = { threadId : sessionState . sessionId , turn : createTurn ( "main-turn" , "completed" ) } ;
442+ for ( const notification of [
443+ ...spawnChild ( sessionState . sessionId ) ,
444+ { method : "turn/completed" , params : completed } satisfies ServerNotification ,
445+ turnDiffUpdated ( CHILD_THREAD_ID , "child-turn" , modifiedFileDiff ( "docs/guide.md" ) ) ,
446+ childTurnCompleted ( ) ,
447+ ] ) {
448+ fixture . sendServerNotification ( notification ) ;
449+ }
450+ return completed ;
451+ } ) ;
452+
453+ await fixture . getCodexAcpAgent ( ) . prompt (
454+ promptWithFileChangeReport ( sessionState . sessionId , "request-late-subagent" ) ,
455+ ) ;
456+
457+ expect ( reportedUpdates ( fixture ) [ 0 ] ) . toMatchObject ( {
458+ _meta : { jetbrains : { air : { agentFileChangeReport : {
459+ status : "reported" ,
460+ paths : [ "/workspace/docs/guide.md" ] ,
461+ } } } } ,
462+ } ) ;
463+ } ) ;
464+
465+ it ( "publishes invalidOutput when a subagent turn diff is oversized" , async ( ) => {
466+ const { fixture, sessionState, awaitTurnCompleted} = await setupNativeSubagentPrompt ( ) ;
467+ awaitTurnCompleted . mockImplementationOnce ( async ( ) : Promise < TurnCompletedNotification > => {
468+ for ( const notification of [
469+ ...spawnChild ( sessionState . sessionId ) ,
470+ turnDiffUpdated ( CHILD_THREAD_ID , "child-turn" , "x" . repeat ( AGENT_FILE_CHANGE_REPORT_MAX_DIFF_BYTES + 1 ) ) ,
471+ childTurnCompleted ( ) ,
472+ ] ) {
473+ fixture . sendServerNotification ( notification ) ;
474+ }
475+ return { threadId : sessionState . sessionId , turn : createTurn ( "main-turn" , "completed" ) } ;
476+ } ) ;
477+
478+ await fixture . getCodexAcpAgent ( ) . prompt (
479+ promptWithFileChangeReport ( sessionState . sessionId , "request-oversized-subagent" ) ,
480+ ) ;
481+
482+ expect ( reportedUpdates ( fixture ) [ 0 ] ) . toMatchObject ( {
483+ _meta : { jetbrains : { air : { agentFileChangeReport : {
484+ status : "unavailable" ,
485+ reason : "invalidOutput" ,
486+ } } } } ,
487+ } ) ;
488+ } ) ;
489+ } ) ;
324490} ) ;
0 commit comments