@@ -33,18 +33,41 @@ describe('PeerConnectionManager attach', () => {
3333 expect ( manager . get ( 'peer-1' ) ) . toBe ( connection ) ;
3434 } ) ;
3535
36- it ( 'reports new capability fields as null (unknown) when the host omits them' , async ( ) => {
37- // An older host's peer_mode_ping does not advertise cancel_tool /
38- // tool_catalog; coercing those to `false` would hide a working capability
39- // on an older Desktop (Interrupt button / tool list). They must parse to
40- // `null` so consumers stay optimistic. See PR #2428 #4.
41- const rpc = createRpc ( ) ;
36+ it ( 'classifies a truly old Desktop from its supported tool catalog probe' , async ( ) => {
37+ const rpc = createLegacyRpc ( 'desktop' ) ;
4238 const manager = createManager ( rpc . deviceRpc ) ;
4339
4440 const connection = await manager . connect ( 'peer-1' , 'Studio' ) ;
45- const caps = connection . getState ( ) . capabilities ;
46- expect ( caps . cancelTool ) . toBeNull ( ) ;
47- expect ( caps . toolCatalog ) . toBeNull ( ) ;
41+ expect ( connection . getState ( ) . capabilities ) . toMatchObject ( {
42+ cancelTool : true ,
43+ toolCatalog : true ,
44+ hostKind : 'desktop' ,
45+ } ) ;
46+ expect ( rpc . commands ( ) ) . toEqual ( [
47+ 'peer_mode_ping' ,
48+ 'get_all_tools_info' ,
49+ 'peer_control_attach' ,
50+ ] ) ;
51+
52+ await vi . advanceTimersByTimeAsync ( KEEPALIVE_MS ) ;
53+ expect ( rpc . commands ( ) . filter ( command => command === 'get_all_tools_info' ) ) . toHaveLength ( 1 ) ;
54+ } ) ;
55+
56+ it ( 'classifies a truly old CLI from an unsupported tool catalog probe' , async ( ) => {
57+ const rpc = createLegacyRpc ( 'cli' ) ;
58+ const manager = createManager ( rpc . deviceRpc ) ;
59+
60+ const connection = await manager . connect ( 'peer-1' , 'CLI' ) ;
61+ expect ( connection . getState ( ) . capabilities ) . toMatchObject ( {
62+ cancelTool : false ,
63+ toolCatalog : false ,
64+ hostKind : 'cli' ,
65+ } ) ;
66+ expect ( rpc . commands ( ) ) . toEqual ( [
67+ 'peer_mode_ping' ,
68+ 'get_all_tools_info' ,
69+ 'peer_control_attach' ,
70+ ] ) ;
4871 } ) ;
4972
5073 it ( 'parses advertised new capability fields as true' , async ( ) => {
@@ -417,6 +440,70 @@ describe('PeerConnectionManager disposal', () => {
417440 // A snapshot was published for the capability change while staying ready.
418441 expect ( snapshots . length ) . toBeGreaterThan ( 0 ) ;
419442 } ) ;
443+
444+ it ( 'does not let a stale health probe classify a replacement connection' , async ( ) => {
445+ const healthCatalog = deferred < string > ( ) ;
446+ const commands : string [ ] = [ ] ;
447+ let catalogCallCount = 0 ;
448+ const deviceRpc = vi . fn ( async ( _target : string , commandJson : string ) : Promise < string > => {
449+ const command = ( JSON . parse ( commandJson ) as { command ?: string } ) . command ?? 'unknown' ;
450+ commands . push ( command ) ;
451+
452+ if ( command === 'peer_mode_ping' ) {
453+ return JSON . stringify ( {
454+ resp : 'host_invoke_result' ,
455+ ok : true ,
456+ value : { capabilities : { } } ,
457+ } ) ;
458+ }
459+ if ( command === 'get_all_tools_info' ) {
460+ catalogCallCount += 1 ;
461+ if ( catalogCallCount === 1 ) {
462+ throw new Error ( 'relay unavailable' ) ;
463+ }
464+ if ( catalogCallCount === 2 ) {
465+ return healthCatalog . promise ;
466+ }
467+ throw new Error ( 'relay unavailable' ) ;
468+ }
469+ return JSON . stringify ( { resp : 'host_invoke_result' , ok : true , value : null } ) ;
470+ } ) ;
471+ const manager = createManager ( deviceRpc ) ;
472+
473+ const first = await manager . connect ( 'peer-1' , 'Studio' ) ;
474+ expect ( first . getState ( ) . capabilities ) . toMatchObject ( {
475+ cancelTool : null ,
476+ toolCatalog : null ,
477+ hostKind : null ,
478+ } ) ;
479+
480+ await vi . advanceTimersByTimeAsync ( KEEPALIVE_MS ) ;
481+ await vi . waitFor ( ( ) => expect ( commands . filter ( command => command === 'get_all_tools_info' ) ) . toHaveLength ( 2 ) ) ;
482+
483+ await manager . dispose ( 'peer-1' , { notifyPeer : false } ) ;
484+ const replacement = await manager . connect ( 'peer-1' , 'Studio' ) ;
485+ expect ( replacement . getState ( ) . capabilities ) . toMatchObject ( {
486+ cancelTool : null ,
487+ toolCatalog : null ,
488+ hostKind : null ,
489+ } ) ;
490+
491+ expect ( catalogCallCount ) . toBe ( 3 ) ;
492+ healthCatalog . resolve ( JSON . stringify ( {
493+ resp : 'host_invoke_result' ,
494+ ok : true ,
495+ value : [ ] ,
496+ } ) ) ;
497+ await Promise . resolve ( ) ;
498+
499+ await vi . advanceTimersByTimeAsync ( KEEPALIVE_MS ) ;
500+ expect ( catalogCallCount ) . toBe ( 4 ) ;
501+ expect ( replacement . getState ( ) . capabilities ) . toMatchObject ( {
502+ cancelTool : null ,
503+ toolCatalog : null ,
504+ hostKind : null ,
505+ } ) ;
506+ } ) ;
420507} ) ;
421508
422509function createManager (
@@ -469,9 +556,12 @@ function createRpc(options: { failCommands?: Set<string> } = {}) {
469556 resp : 'host_invoke_result' ,
470557 ok : true ,
471558 value : {
559+ host_type : 'desktop' ,
472560 capabilities : {
473561 idempotent_dialog_submit : true ,
474562 token_usage_statistics : true ,
563+ cancel_tool : true ,
564+ tool_catalog : true ,
475565 } ,
476566 } ,
477567 } ) ;
@@ -495,6 +585,52 @@ function createRpc(options: { failCommands?: Set<string> } = {}) {
495585 } ;
496586}
497587
588+ function createLegacyRpc ( kind : 'desktop' | 'cli' ) {
589+ const calls : RpcCall [ ] = [ ] ;
590+ const deviceRpc = vi . fn ( async ( _target : string , commandJson : string ) : Promise < string > => {
591+ const parsed = JSON . parse ( commandJson ) as { command ?: string ; args ?: Record < string , unknown > } ;
592+ const command = parsed . command ?? 'unknown' ;
593+ calls . push ( { command, args : parsed . args ?? { } } ) ;
594+
595+ if ( command === 'peer_mode_ping' ) {
596+ return JSON . stringify ( {
597+ resp : 'host_invoke_result' ,
598+ ok : true ,
599+ value : {
600+ ok : true ,
601+ peer : true ,
602+ device_id : 'legacy-peer' ,
603+ capabilities : {
604+ idempotent_dialog_submit : true ,
605+ targeted_session_rollback : true ,
606+ token_usage_statistics : true ,
607+ } ,
608+ } ,
609+ } ) ;
610+ }
611+ if ( command === 'get_all_tools_info' ) {
612+ if ( kind === 'cli' ) {
613+ return JSON . stringify ( {
614+ resp : 'host_invoke_result' ,
615+ ok : false ,
616+ error : "command 'get_all_tools_info' is not supported on CLI peer host" ,
617+ } ) ;
618+ }
619+ return JSON . stringify ( {
620+ resp : 'host_invoke_result' ,
621+ ok : true ,
622+ value : [ ] ,
623+ } ) ;
624+ }
625+ return JSON . stringify ( { resp : 'host_invoke_result' , ok : true , value : null } ) ;
626+ } ) ;
627+
628+ return {
629+ deviceRpc,
630+ commands : ( ) => calls . map ( call => call . command ) ,
631+ } ;
632+ }
633+
498634function deferred < T > ( ) {
499635 let resolve ! : ( value : T ) => void ;
500636 const promise = new Promise < T > ( res => {
0 commit comments