@@ -7,6 +7,7 @@ import { expect, it } from '@rstest/core';
77
88import { TargetRegistry } from '../src/adapters/registry.ts' ;
99import type { TargetAdapter } from '../src/adapters/types.ts' ;
10+ import { parseArtifactManifest } from '../src/build/manifest.ts' ;
1011import type { NormalizedPlugin } from '../src/core/types.ts' ;
1112import { runCli } from '../src/cli.ts' ;
1213import { captureCliTerminal } from './support/cli-terminal.ts' ;
@@ -502,6 +503,114 @@ it('normalizes a relative project root once before constructing every dev servic
502503 }
503504} , 30_000 ) ;
504505
506+ it ( 'publishes routes created and deleted below source roots absent at startup' , async ( ) => {
507+ const project = await createProjectFixture ( {
508+ config : [
509+ "import { defineConfig } from 'agent-bundle/config';" ,
510+ '' ,
511+ 'export default defineConfig({' ,
512+ " plugin: { name: 'watcher-route-roots', version: '1.0.0' }," ,
513+ " targets: ['claude', 'codex']," ,
514+ '});' ,
515+ '' ,
516+ ] . join ( '\n' ) ,
517+ files : {
518+ 'package.json' : JSON . stringify ( {
519+ dependencies : {
520+ '@agent-bundle/runtime' : 'workspace:*' ,
521+ '@modelcontextprotocol/server' : '2.0.0' ,
522+ } ,
523+ name : 'watcher-route-roots' ,
524+ type : 'module' ,
525+ version : '1.0.0' ,
526+ } ) ,
527+ } ,
528+ } ) ;
529+ const publications = Array . from ( { length : 5 } , ( ) => Promise . withResolvers < string > ( ) ) ;
530+ const publication = ( index : number ) : Promise < string > =>
531+ within ( publications [ index ] ! . promise , 20_000 * timeScale ) ;
532+ let publicationCount = 0 ;
533+ let server : Awaited < ReturnType < typeof startDevServer > > | undefined ;
534+ await symlink ( join ( process . cwd ( ) , 'examples' , 'audiobook-curator' , 'node_modules' ) , join ( project . root , 'node_modules' ) , 'dir' ) ;
535+ try {
536+ server = await startDevServer ( {
537+ open : false ,
538+ port : 0 ,
539+ root : project . root ,
540+ testing : {
541+ startForegroundServer : async ( options ) => {
542+ const subscription = options . eventHub . subscribe ( ( event ) => {
543+ if ( event . type !== 'artifact.available' ) return ;
544+ publications [ publicationCount ] ?. resolve ( event . epochId ) ;
545+ publicationCount += 1 ;
546+ } ) ;
547+ await options . coordinator . start ( ) ;
548+ return {
549+ close : async ( ) => {
550+ subscription . unsubscribe ( ) ;
551+ await options . coordinator . close ( ) ;
552+ } ,
553+ url : 'http://127.0.0.1:49124' ,
554+ } ;
555+ } ,
556+ } ,
557+ } ) ;
558+ const activeManifest = async ( ) => {
559+ const artifact = server ! . status ( ) . artifact ;
560+ if ( artifact . state !== 'active' ) throw new Error ( 'Expected an active development artifact.' ) ;
561+ return parseArtifactManifest ( await readFile ( artifact . activeEpoch . manifestPath , 'utf8' ) ) ;
562+ } ;
563+ await publication ( 0 ) ;
564+ expect ( ( await activeManifest ( ) ) . routes ) . toMatchObject ( { events : [ ] , servers : [ ] } ) ;
565+
566+ const eventPath = join ( project . root , 'src' , 'events' , 'session' , 'start.ts' ) ;
567+ await mkdir ( join ( project . root , 'src' , 'events' , 'session' ) , { recursive : true } ) ;
568+ await writeFile ( eventPath , [
569+ "export const config = { runtime: 'standalone' };" ,
570+ "export default async function SessionStart() { return { outcome: 'continue' }; }" ,
571+ '' ,
572+ ] . join ( '\n' ) ) ;
573+ await publication ( 1 ) ;
574+ const withEvent = await activeManifest ( ) ;
575+ expect ( withEvent . routes . events . map ( ( { id } ) => id ) ) . toEqual ( [ 'event:session/start' ] ) ;
576+ expect ( withEvent . executables . hooks . map ( ( { event, host, kind } ) => ( { event, host, kind } ) ) ) . toEqual ( [
577+ { event : 'sessionStart' , host : 'claude' , kind : 'event-route' } ,
578+ { event : 'sessionStart' , host : 'codex' , kind : 'event-route' } ,
579+ ] ) ;
580+
581+ await rm ( eventPath ) ;
582+ await publication ( 2 ) ;
583+ const withoutEvent = await activeManifest ( ) ;
584+ expect ( withoutEvent . routes . events ) . toEqual ( [ ] ) ;
585+ expect ( withoutEvent . executables . hooks ) . toEqual ( [ ] ) ;
586+
587+ const toolPath = join ( project . root , 'src' , 'mcp' , 'probe' , 'tools' , 'status.ts' ) ;
588+ await mkdir ( join ( project . root , 'src' , 'mcp' , 'probe' , 'tools' ) , { recursive : true } ) ;
589+ await writeFile (
590+ toolPath ,
591+ 'export const inputSchema = {}; export const resultSchema = {}; export default async () => undefined;\n' ,
592+ ) ;
593+ await publication ( 3 ) ;
594+ const withTool = await activeManifest ( ) ;
595+ expect ( withTool . routes . servers . map ( ( { id, routes } ) => ( { id, routes : routes . map ( ( { id } ) => id ) } ) ) ) . toEqual ( [
596+ { id : 'mcp:probe' , routes : [ 'tool:probe/status' ] } ,
597+ ] ) ;
598+ expect ( withTool . executables . mcpServers ) . toEqual ( [
599+ expect . objectContaining ( { hosts : [ 'claude' , 'codex' ] , name : 'probe' } ) ,
600+ ] ) ;
601+
602+ await rm ( toolPath ) ;
603+ await publication ( 4 ) ;
604+ const withoutTool = await activeManifest ( ) ;
605+ expect ( withoutTool . routes . servers ) . toEqual ( [ ] ) ;
606+ expect ( withoutTool . executables . mcpServers ) . toEqual ( [ ] ) ;
607+ } finally {
608+ await server ?. close ( ) . catch ( ( ) => undefined ) ;
609+ await removeProjectFixture ( project . root ) ;
610+ }
611+ expect ( publicationCount ) . toBe ( 5 ) ;
612+ } , 120_000 ) ;
613+
505614it ( 'latches a runtime declaration added to an ordinary Workbench session as restart-required' , async ( ) => {
506615 const project = await createProjectFixture ( ) ;
507616 const assetsRoot = await mkdtemp ( join ( tmpdir ( ) , 'agent-bundle-workbench-runtime-topology-' ) ) ;
0 commit comments