@@ -7,7 +7,7 @@ import {createTestFixture, createCodexMockTestFixture, createTestSessionState, t
77import type { ServerNotification } from "../../app-server" ;
88import type { SessionState } from "../../CodexAcpServer" ;
99import { AgentMode } from "../../AgentMode" ;
10- import type { ListMcpServerStatusResponse , Model , SkillsListResponse } from "../../app-server/v2" ;
10+ import type { ListMcpServerStatusResponse , Model , SkillsListResponse , TurnStartParams } from "../../app-server/v2" ;
1111import type { RateLimitsMap } from "../../RateLimitsMap" ;
1212import { ModelId } from "../../ModelId" ;
1313
@@ -407,6 +407,32 @@ describe('ACP server test', { timeout: 40_000 }, () => {
407407 return onServerNotification ;
408408 }
409409
410+ function createTurn ( id : string , status : "inProgress" | "completed" ) {
411+ return {
412+ id,
413+ items : [ ] ,
414+ status,
415+ error : null ,
416+ startedAt : null ,
417+ completedAt : null ,
418+ durationMs : null ,
419+ } ;
420+ }
421+
422+ function createTurnCompletedNotification ( threadId : string , turnId : string ) : ServerNotification {
423+ return {
424+ method : "turn/completed" ,
425+ params : {
426+ threadId,
427+ turn : createTurn ( turnId , "completed" ) ,
428+ } ,
429+ } ;
430+ }
431+
432+ async function flushAsyncWork ( ) : Promise < void > {
433+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
434+ }
435+
410436 it ( 'should map events from dump' , async ( ) => {
411437 fixture . getCodexAppServerClient ( ) . onServerNotification = loadNotifications ( ) ;
412438
@@ -462,9 +488,9 @@ describe('ACP server test', { timeout: 40_000 }, () => {
462488
463489 // Trigger notifications after both prompts - should produce only 3 events, not 6
464490 const serverNotifications : ServerNotification [ ] = [
465- { method : "item/agentMessage/delta" , params : { threadId : "string " , turnId : "string" , itemId : "string" , delta : "He" , } } ,
466- { method : "item/agentMessage/delta" , params : { threadId : "string " , turnId : "string" , itemId : "string" , delta : "ll" , } } ,
467- { method : "item/agentMessage/delta" , params : { threadId : "string " , turnId : "string" , itemId : "string" , delta : "o!" , } } ,
491+ { method : "item/agentMessage/delta" , params : { threadId : "id " , turnId : "string" , itemId : "string" , delta : "He" , } } ,
492+ { method : "item/agentMessage/delta" , params : { threadId : "id " , turnId : "string" , itemId : "string" , delta : "ll" , } } ,
493+ { method : "item/agentMessage/delta" , params : { threadId : "id " , turnId : "string" , itemId : "string" , delta : "o!" , } } ,
468494 ] ;
469495 for ( const notification of serverNotifications ) {
470496 mockFixture . sendServerNotification ( notification ) ;
@@ -486,10 +512,6 @@ describe('ACP server test', { timeout: 40_000 }, () => {
486512 mockFixture . getCodexAppServerClient ( ) . turnStart = vi . fn ( ) . mockResolvedValue ( {
487513 turn : { id : "turn-id" , items : [ ] , status : "inProgress" , error : null }
488514 } ) ;
489- mockFixture . getCodexAppServerClient ( ) . awaitTurnCompleted = vi . fn ( ) . mockResolvedValue ( {
490- threadId : "id" ,
491- turn : { id : "turn-id" , items : [ ] , status : "completed" , error : null }
492- } ) ;
493515
494516 const sessionState1 : SessionState = createTestSessionState ( {
495517 sessionId : "session-1" ,
@@ -506,15 +528,23 @@ describe('ACP server test', { timeout: 40_000 }, () => {
506528 return sessionId === "session-1" ? sessionState1 : sessionState2 ;
507529 } ) ;
508530
531+ // awaitTurnCompleted is per-turn; resolve the matching thread and turn.
532+ mockFixture . getCodexAppServerClient ( ) . awaitTurnCompleted = vi . fn ( ) . mockImplementation ( ( threadId : string , turnId : string ) => Promise . resolve ( {
533+ threadId,
534+ turn : createTurn ( turnId , "completed" )
535+ } ) ) ;
536+
509537 // Start prompts for two different sessions
510538 await codexAcpAgent . prompt ( { sessionId : "session-1" , prompt : [ { type : "text" , text : "Message to session 1" } ] } ) ;
511539 await codexAcpAgent . prompt ( { sessionId : "session-2" , prompt : [ { type : "text" , text : "Message to session 2" } ] } ) ;
512540
513541 mockFixture . clearAcpConnectionDump ( ) ;
514542
515- // Trigger notifications - both session handlers should receive them
543+ // Each notification carries the threadId of the session it belongs to,
544+ // and must only be dispatched to that session.
516545 const serverNotifications : ServerNotification [ ] = [
517- { method : "item/agentMessage/delta" , params : { threadId : "string" , turnId : "string" , itemId : "string" , delta : "Hello" , } } ,
546+ { method : "item/agentMessage/delta" , params : { threadId : "session-1" , turnId : "string" , itemId : "string" , delta : "Hello-1" , } } ,
547+ { method : "item/agentMessage/delta" , params : { threadId : "session-2" , turnId : "string" , itemId : "string" , delta : "Hello-2" , } } ,
518548 ] ;
519549 for ( const notification of serverNotifications ) {
520550 mockFixture . sendServerNotification ( notification ) ;
@@ -523,18 +553,100 @@ describe('ACP server test', { timeout: 40_000 }, () => {
523553 // Wait for async handlers to complete
524554 await vi . waitFor ( ( ) => {
525555 const dump = mockFixture . getAcpConnectionDump ( [ ] ) ;
526- expect ( dump . length ) . toBeGreaterThan ( 0 ) ;
556+ expect ( dump . length ) . toBeGreaterThanOrEqual ( 2 ) ;
527557 } ) ;
528558
529- // Should have 2 events - one for each session's handler
559+ // Should have exactly 2 events - the session-1 delta only on session-1, and
560+ // the session-2 delta only on session-2 (no cross-session pollution).
530561 await expect ( mockFixture . getAcpConnectionDump ( [ ] ) ) . toMatchFileSnapshot ( "data/multiple-sessions.json" ) ;
531562 } ) ;
532563
564+ it ( 'should complete concurrent prompts by matching thread and turn id' , async ( ) => {
565+ const mockFixture = createCodexMockTestFixture ( ) ;
566+ const codexAcpAgent = mockFixture . getCodexAcpAgent ( ) ;
567+
568+ const turnIds = new Map ( [
569+ [ "session-1" , "turn-1" ] ,
570+ [ "session-2" , "turn-2" ] ,
571+ ] ) ;
572+ const turnStart = vi . fn ( ) . mockImplementation ( ( params : TurnStartParams ) => Promise . resolve ( {
573+ turn : createTurn ( turnIds . get ( params . threadId ) ?? "unknown-turn" , "inProgress" ) ,
574+ } ) ) ;
575+ mockFixture . getCodexAppServerClient ( ) . turnStart = turnStart ;
576+
577+ const sessionState1 : SessionState = createTestSessionState ( {
578+ sessionId : "session-1" ,
579+ currentModelId : "model-id[effort]" ,
580+ agentMode : AgentMode . DEFAULT_AGENT_MODE
581+ } ) ;
582+ const sessionState2 : SessionState = createTestSessionState ( {
583+ sessionId : "session-2" ,
584+ currentModelId : "model-id[effort]" ,
585+ agentMode : AgentMode . DEFAULT_AGENT_MODE
586+ } ) ;
587+ vi . spyOn ( codexAcpAgent , "getSessionState" ) . mockImplementation ( ( sessionId : string ) => {
588+ return sessionId === "session-1" ? sessionState1 : sessionState2 ;
589+ } ) ;
590+
591+ const prompt1 = codexAcpAgent . prompt ( { sessionId : "session-1" , prompt : [ { type : "text" , text : "Message to session 1" } ] } ) ;
592+ const prompt2 = codexAcpAgent . prompt ( { sessionId : "session-2" , prompt : [ { type : "text" , text : "Message to session 2" } ] } ) ;
593+
594+ await vi . waitFor ( ( ) => {
595+ expect ( turnStart ) . toHaveBeenCalledTimes ( 2 ) ;
596+ } ) ;
597+
598+ let prompt1Settled = false ;
599+ void prompt1 . then ( ( ) => {
600+ prompt1Settled = true ;
601+ } , ( ) => {
602+ prompt1Settled = true ;
603+ } ) ;
604+
605+ mockFixture . sendServerNotification ( createTurnCompletedNotification ( "session-1" , "old-turn" ) ) ;
606+ await flushAsyncWork ( ) ;
607+ expect ( prompt1Settled ) . toBe ( false ) ;
608+
609+ mockFixture . sendServerNotification ( createTurnCompletedNotification ( "session-2" , "turn-2" ) ) ;
610+ await expect ( prompt2 ) . resolves . toMatchObject ( { stopReason : "end_turn" } ) ;
611+ expect ( prompt1Settled ) . toBe ( false ) ;
612+
613+ mockFixture . sendServerNotification ( createTurnCompletedNotification ( "session-1" , "turn-1" ) ) ;
614+ await expect ( prompt1 ) . resolves . toMatchObject ( { stopReason : "end_turn" } ) ;
615+ } ) ;
616+
617+ it ( 'should handle a turn completion that arrives before awaitTurnCompleted is called' , async ( ) => {
618+ const mockFixture = createCodexMockTestFixture ( ) ;
619+ const codexAcpAgent = mockFixture . getCodexAcpAgent ( ) ;
620+
621+ mockFixture . getCodexAppServerClient ( ) . turnStart = vi . fn ( ) . mockImplementation ( ( params : TurnStartParams ) => {
622+ mockFixture . sendServerNotification ( createTurnCompletedNotification ( params . threadId , "fast-turn" ) ) ;
623+ return Promise . resolve ( {
624+ turn : createTurn ( "fast-turn" , "inProgress" ) ,
625+ } ) ;
626+ } ) ;
627+
628+ vi . spyOn ( codexAcpAgent , "getSessionState" ) . mockReturnValue ( createTestSessionState ( {
629+ sessionId : "fast-session" ,
630+ currentModelId : "model-id[effort]" ,
631+ agentMode : AgentMode . DEFAULT_AGENT_MODE
632+ } ) ) ;
633+
634+ await expect ( codexAcpAgent . prompt ( {
635+ sessionId : "fast-session" ,
636+ prompt : [ { type : "text" , text : "Fast completion" } ] ,
637+ } ) ) . resolves . toMatchObject ( { stopReason : "end_turn" } ) ;
638+ } ) ;
639+
533640 it ( 'should send attachments as prompt items' , async ( ) => {
534641 const mockFixture = createCodexMockTestFixture ( ) ;
535642 const codexAcpAgent = mockFixture . getCodexAcpAgent ( ) ;
536643 const codexAppServerClient = mockFixture . getCodexAppServerClient ( ) ;
537644
645+ const realTurnStart = codexAppServerClient . turnStart . bind ( codexAppServerClient ) ;
646+ vi . spyOn ( codexAppServerClient , "turnStart" ) . mockImplementation ( async ( params ) => {
647+ await realTurnStart ( params ) ;
648+ return { turn : createTurn ( "turn-id" , "inProgress" ) } ;
649+ } ) ;
538650 vi . spyOn ( codexAppServerClient , "awaitTurnCompleted" ) . mockResolvedValue ( {
539651 threadId : "session-id" ,
540652 turn : {
0 commit comments