@@ -8,18 +8,24 @@ import type { AgentProviderCallbacks, IpcRegistrar } from '../types.js';
88
99interface MockCallbacks {
1010 callbacks : AgentProviderCallbacks ;
11- handlers : Map < string , IpcHandler > ;
11+ handlers : Map < string , IpcHandler < unknown , unknown > > ;
1212 publishedPaths : string [ ] [ ] ;
1313}
1414
15- function createMockCallbacks ( options ?: { resolveGitInfo ?: AgentProviderCallbacks [ 'resolveGitInfo' ] } ) : MockCallbacks {
16- const handlers = new Map < string , IpcHandler > ( ) ;
15+ function createMockCallbacks ( options ?: {
16+ resolveGitInfo ?: AgentProviderCallbacks [ 'resolveGitInfo' ] ;
17+ openSessionInClaudeExtension ?: AgentProviderCallbacks [ 'openSessionInClaudeExtension' ] ;
18+ port ?: number ;
19+ agentDiscoveryDir ?: string ;
20+ } ) : MockCallbacks {
21+ const handlers = new Map < string , IpcHandler < unknown , unknown > > ( ) ;
1722 const publishedPaths : string [ ] [ ] = [ ] ;
1823
1924 const ipc : IpcRegistrar = {
20- port : 1234 ,
25+ port : options ?. port ?? 1234 ,
26+ agentDiscoveryDir : options ?. agentDiscoveryDir ,
2127 registerHandler : < Request , Response > ( name : string , handler : IpcHandler < Request , Response > ) => {
22- handlers . set ( name , handler as unknown as IpcHandler ) ;
28+ handlers . set ( name , handler as unknown as IpcHandler < unknown , unknown > ) ;
2329 return createDisposable ( ( ) => {
2430 handlers . delete ( name ) ;
2531 } ) ;
@@ -35,6 +41,7 @@ function createMockCallbacks(options?: { resolveGitInfo?: AgentProviderCallbacks
3541 ipc : ipc ,
3642 runCLICommand : ( ) => Promise . resolve ( '[]' ) ,
3743 resolveGitInfo : options ?. resolveGitInfo ,
44+ openSessionInClaudeExtension : options ?. openSessionInClaudeExtension ,
3845 } ;
3946
4047 return { callbacks : callbacks , handlers : handlers , publishedPaths : publishedPaths } ;
@@ -503,6 +510,240 @@ suite('ClaudeCodeProvider', () => {
503510 }
504511 } ) ;
505512 } ) ;
513+
514+ suite ( 'agents/sessions/open IPC handler' , ( ) => {
515+ test ( 'invokes the host callback with the requested sessionId' , async ( ) => {
516+ const calls : string [ ] = [ ] ;
517+ const { callbacks, handlers } = createMockCallbacks ( {
518+ openSessionInClaudeExtension : sessionId => {
519+ calls . push ( sessionId ) ;
520+ return Promise . resolve ( ) ;
521+ } ,
522+ } ) ;
523+ const provider = new ClaudeCodeProvider ( callbacks ) ;
524+ try {
525+ provider . start ( [ '/repo' ] ) ;
526+ await flushMicrotasks ( ) ;
527+
528+ const handler = handlers . get ( 'agents/sessions/open' ) ;
529+ assert . ok ( handler != null , 'agents/sessions/open handler should be registered' ) ;
530+
531+ const response = await handler ( { sessionId : 'sess-1' } , new URLSearchParams ( ) ) ;
532+ assert . deepStrictEqual ( calls , [ 'sess-1' ] ) ;
533+ assert . deepStrictEqual ( response , { } ) ;
534+ } finally {
535+ provider . dispose ( ) ;
536+ }
537+ } ) ;
538+
539+ test ( 'returns {} without invoking the callback when sessionId is missing' , async ( ) => {
540+ let called = false ;
541+ const { callbacks, handlers } = createMockCallbacks ( {
542+ openSessionInClaudeExtension : ( ) => {
543+ called = true ;
544+ return Promise . resolve ( ) ;
545+ } ,
546+ } ) ;
547+ const provider = new ClaudeCodeProvider ( callbacks ) ;
548+ try {
549+ provider . start ( [ '/repo' ] ) ;
550+ await flushMicrotasks ( ) ;
551+
552+ const handler = handlers . get ( 'agents/sessions/open' ) ! ;
553+ const response = await handler ( { } , new URLSearchParams ( ) ) ;
554+ assert . strictEqual ( called , false , 'callback must not run when sessionId is absent' ) ;
555+ assert . deepStrictEqual ( response , { } ) ;
556+ } finally {
557+ provider . dispose ( ) ;
558+ }
559+ } ) ;
560+
561+ test ( 'returns {} when the host did not wire openSessionInClaudeExtension' , async ( ) => {
562+ const { callbacks, handlers } = createMockCallbacks ( ) ;
563+ const provider = new ClaudeCodeProvider ( callbacks ) ;
564+ try {
565+ provider . start ( [ '/repo' ] ) ;
566+ await flushMicrotasks ( ) ;
567+
568+ const handler = handlers . get ( 'agents/sessions/open' ) ! ;
569+ const response = await handler ( { sessionId : 'sess-1' } , new URLSearchParams ( ) ) ;
570+ assert . deepStrictEqual ( response , { } ) ;
571+ } finally {
572+ provider . dispose ( ) ;
573+ }
574+ } ) ;
575+
576+ test ( 'swallows callback errors so the peer never sees a 500' , async ( ) => {
577+ const { callbacks, handlers } = createMockCallbacks ( {
578+ openSessionInClaudeExtension : ( ) => Promise . reject ( new Error ( 'extension not installed' ) ) ,
579+ } ) ;
580+ const provider = new ClaudeCodeProvider ( callbacks ) ;
581+ try {
582+ provider . start ( [ '/repo' ] ) ;
583+ await flushMicrotasks ( ) ;
584+
585+ const handler = handlers . get ( 'agents/sessions/open' ) ! ;
586+ const response = await handler ( { sessionId : 'sess-1' } , new URLSearchParams ( ) ) ;
587+ assert . deepStrictEqual ( response , { } ) ;
588+ } finally {
589+ provider . dispose ( ) ;
590+ }
591+ } ) ;
592+ } ) ;
593+
594+ suite ( 'notifyPeerOpenSession' , ( ) => {
595+ test ( "skips the discovery file matching this provider's own port" , async ( ) => {
596+ const { default : http } = await import ( 'node:http' ) ;
597+ const { mkdtemp, rm, writeFile } = await import ( 'node:fs/promises' ) ;
598+ const { tmpdir } = await import ( 'node:os' ) ;
599+ const { join } = await import ( 'node:path' ) ;
600+
601+ const dir = await mkdtemp ( join ( tmpdir ( ) , 'gitlens-discovery-self-' ) ) ;
602+ const hits : string [ ] = [ ] ;
603+ const server = http . createServer ( ( req , res ) => {
604+ hits . push ( req . url ?? '' ) ;
605+ res . writeHead ( 200 ) ;
606+ res . end ( '{}' ) ;
607+ } ) ;
608+ await new Promise < void > ( resolve => server . listen ( 0 , '127.0.0.1' , resolve ) ) ;
609+ const port = ( server . address ( ) as { port : number } ) . port ;
610+ try {
611+ await writeFile (
612+ join ( dir , 'gitlens-ipc-server-self.json' ) ,
613+ JSON . stringify ( {
614+ token : 't' ,
615+ address : `http://127.0.0.1:${ port } ` ,
616+ port : port ,
617+ workspacePaths : [ '/repo' ] ,
618+ } ) ,
619+ ) ;
620+
621+ const { callbacks } = createMockCallbacks ( { port : port , agentDiscoveryDir : dir } ) ;
622+ const provider = new ClaudeCodeProvider ( callbacks ) ;
623+ try {
624+ provider . start ( [ '/repo' ] ) ;
625+ await flushMicrotasks ( ) ;
626+ hits . length = 0 ; // ignore any pre-existing list-route hits (there should be none)
627+ await provider . notifyPeerOpenSession ( '/repo' , 'sess-1' ) ;
628+ assert . deepStrictEqual (
629+ hits . filter ( u => u === '/agents/sessions/open' ) ,
630+ [ ] ,
631+ 'own-port discovery file must be skipped' ,
632+ ) ;
633+ } finally {
634+ provider . dispose ( ) ;
635+ }
636+ } finally {
637+ await new Promise < void > ( resolve => server . close ( ( ) => resolve ( ) ) ) ;
638+ await rm ( dir , { recursive : true , force : true } ) ;
639+ }
640+ } ) ;
641+
642+ test ( 'skips peers whose workspacePaths do not include the target' , async ( ) => {
643+ const { default : http } = await import ( 'node:http' ) ;
644+ const { mkdtemp, rm, writeFile } = await import ( 'node:fs/promises' ) ;
645+ const { tmpdir } = await import ( 'node:os' ) ;
646+ const { join } = await import ( 'node:path' ) ;
647+
648+ const dir = await mkdtemp ( join ( tmpdir ( ) , 'gitlens-discovery-mismatch-' ) ) ;
649+ const hits : string [ ] = [ ] ;
650+ const server = http . createServer ( ( req , res ) => {
651+ hits . push ( req . url ?? '' ) ;
652+ res . writeHead ( 200 ) ;
653+ res . end ( '{}' ) ;
654+ } ) ;
655+ await new Promise < void > ( resolve => server . listen ( 0 , '127.0.0.1' , resolve ) ) ;
656+ const peerPort = ( server . address ( ) as { port : number } ) . port ;
657+ try {
658+ await writeFile (
659+ join ( dir , 'gitlens-ipc-server-other.json' ) ,
660+ JSON . stringify ( {
661+ token : 't' ,
662+ address : `http://127.0.0.1:${ peerPort } ` ,
663+ port : peerPort ,
664+ workspacePaths : [ '/other/workspace' ] ,
665+ } ) ,
666+ ) ;
667+
668+ const { callbacks } = createMockCallbacks ( { port : peerPort + 1 , agentDiscoveryDir : dir } ) ;
669+ const provider = new ClaudeCodeProvider ( callbacks ) ;
670+ try {
671+ provider . start ( [ '/repo' ] ) ;
672+ await flushMicrotasks ( ) ;
673+ hits . length = 0 ; // ignore `/agents/sessions/list` from querySiblingWindowSessions
674+ await provider . notifyPeerOpenSession ( '/repo' , 'sess-1' ) ;
675+ assert . deepStrictEqual (
676+ hits . filter ( u => u === '/agents/sessions/open' ) ,
677+ [ ] ,
678+ 'mismatched-workspace peer must not be POSTed' ,
679+ ) ;
680+ } finally {
681+ provider . dispose ( ) ;
682+ }
683+ } finally {
684+ await new Promise < void > ( resolve => server . close ( ( ) => resolve ( ) ) ) ;
685+ await rm ( dir , { recursive : true , force : true } ) ;
686+ }
687+ } ) ;
688+
689+ test ( 'POSTs the sessionId to peers whose workspacePaths include the (normalized) target' , async ( ) => {
690+ const { default : http } = await import ( 'node:http' ) ;
691+ const { mkdtemp, rm, writeFile } = await import ( 'node:fs/promises' ) ;
692+ const { tmpdir } = await import ( 'node:os' ) ;
693+ const { join } = await import ( 'node:path' ) ;
694+
695+ const dir = await mkdtemp ( join ( tmpdir ( ) , 'gitlens-discovery-match-' ) ) ;
696+ const requests : { url : string ; auth : string | undefined ; body : string } [ ] = [ ] ;
697+ const server = http . createServer ( ( req , res ) => {
698+ const chunks : Buffer [ ] = [ ] ;
699+ req . on ( 'data' , c => chunks . push ( c as Buffer ) ) ;
700+ req . on ( 'end' , ( ) => {
701+ requests . push ( {
702+ url : req . url ?? '' ,
703+ auth : req . headers [ 'authorization' ] ,
704+ body : Buffer . concat ( chunks ) . toString ( 'utf8' ) ,
705+ } ) ;
706+ res . writeHead ( 200 ) ;
707+ res . end ( '{}' ) ;
708+ } ) ;
709+ } ) ;
710+ await new Promise < void > ( resolve => server . listen ( 0 , '127.0.0.1' , resolve ) ) ;
711+ const peerPort = ( server . address ( ) as { port : number } ) . port ;
712+ try {
713+ await writeFile (
714+ join ( dir , 'gitlens-ipc-server-peer.json' ) ,
715+ JSON . stringify ( {
716+ token : 'peer-token' ,
717+ address : `http://127.0.0.1:${ peerPort } ` ,
718+ port : peerPort ,
719+ // Mixed-separator path on purpose — `notifyPeerOpenSession` normalizes both sides.
720+ workspacePaths : [ 'd:\\PROJ\\GKGL\\vscode-gitlens' ] ,
721+ } ) ,
722+ ) ;
723+
724+ const { callbacks } = createMockCallbacks ( { port : peerPort + 1 , agentDiscoveryDir : dir } ) ;
725+ const provider = new ClaudeCodeProvider ( callbacks ) ;
726+ try {
727+ provider . start ( [ '/somewhere/else' ] ) ;
728+ await flushMicrotasks ( ) ;
729+ // Ignore the unrelated `/agents/sessions/list` POST that `querySiblingWindowSessions`
730+ // fires on start — we only care about what `notifyPeerOpenSession` does.
731+ requests . length = 0 ;
732+ await provider . notifyPeerOpenSession ( 'd:/PROJ/GKGL/vscode-gitlens' , 'sess-42' ) ;
733+
734+ const openRequests = requests . filter ( r => r . url === '/agents/sessions/open' ) ;
735+ assert . strictEqual ( openRequests . length , 1 , 'matching peer should receive exactly one open POST' ) ;
736+ assert . strictEqual ( openRequests [ 0 ] . auth , 'Bearer peer-token' ) ;
737+ assert . deepStrictEqual ( JSON . parse ( openRequests [ 0 ] . body ) , { sessionId : 'sess-42' } ) ;
738+ } finally {
739+ provider . dispose ( ) ;
740+ }
741+ } finally {
742+ await new Promise < void > ( resolve => server . close ( ( ) => resolve ( ) ) ) ;
743+ await rm ( dir , { recursive : true , force : true } ) ;
744+ }
745+ } ) ;
746+ } ) ;
506747} ) ;
507748
508749/** Drop-in transcript reader for provider tests — records calls and returns canned titles. */
0 commit comments