11import { describe , expect , it , beforeEach , afterEach , mock , spyOn } from 'bun:test'
2- import { getMatchingSpawn , handleSpawnAgents } from '../tools/handlers/tool/spawn-agents'
2+ import { handleSpawnAgents } from '../tools/handlers/tool/spawn-agents'
3+ import { handleSpawnAgentInline } from '../tools/handlers/tool/spawn-agent-inline'
4+ import { getMatchingSpawn } from '../tools/handlers/tool/spawn-agent-utils'
35import { TEST_USER_ID } from '@codebuff/common/constants'
46import { getInitialSessionState } from '@codebuff/common/types/session-state'
57import { mockFileContext , MockWebSocket } from './test-utils'
@@ -14,6 +16,25 @@ describe('Spawn Agents Permissions', () => {
1416 let mockSendSubagentChunk : any
1517 let mockLoopAgentSteps : any
1618
19+ const createMockAgent = ( id : string , spawnableAgents : string [ ] = [ ] ) : AgentTemplate => ( {
20+ id,
21+ displayName : `Mock ${ id } ` ,
22+ outputMode : 'last_message' as const ,
23+ inputSchema : {
24+ prompt : {
25+ safeParse : ( ) => ( { success : true } ) ,
26+ } as any ,
27+ } ,
28+ spawnerPrompt : '' ,
29+ model : '' ,
30+ includeMessageHistory : true ,
31+ toolNames : [ ] ,
32+ spawnableAgents,
33+ systemPrompt : '' ,
34+ instructionsPrompt : '' ,
35+ stepPrompt : '' ,
36+ } )
37+
1738 beforeEach ( ( ) => {
1839 // Mock logger to reduce noise in tests
1940 spyOn ( loggerModule . logger , 'debug' ) . mockImplementation ( ( ) => { } )
@@ -174,25 +195,6 @@ describe('Spawn Agents Permissions', () => {
174195 } )
175196
176197 describe ( 'handleSpawnAgents permission validation' , ( ) => {
177- const createMockAgent = ( id : string , spawnableAgents : string [ ] = [ ] ) : AgentTemplate => ( {
178- id,
179- displayName : `Mock ${ id } ` ,
180- outputMode : 'last_message' as const ,
181- inputSchema : {
182- prompt : {
183- safeParse : ( ) => ( { success : true } ) ,
184- } as any ,
185- } ,
186- spawnerPrompt : '' ,
187- model : '' ,
188- includeMessageHistory : true ,
189- toolNames : [ ] ,
190- spawnableAgents,
191- systemPrompt : '' ,
192- instructionsPrompt : '' ,
193- stepPrompt : '' ,
194- } )
195-
196198 const createSpawnToolCall = ( agentType : string , prompt = 'test prompt' ) : CodebuffToolCall < 'spawn_agents' > => ( {
197199 toolName : 'spawn_agents' as const ,
198200 toolCallId : 'test-tool-call-id' ,
@@ -436,4 +438,213 @@ describe('Spawn Agents Permissions', () => {
436438 expect ( mockLoopAgentSteps ) . toHaveBeenCalledTimes ( 1 ) // Only thinker was spawned
437439 } )
438440 } )
441+
442+ describe ( 'handleSpawnAgentInline permission validation' , ( ) => {
443+ const createInlineSpawnToolCall = ( agentType : string , prompt = 'test prompt' ) : CodebuffToolCall < 'spawn_agent_inline' > => ( {
444+ toolName : 'spawn_agent_inline' as const ,
445+ toolCallId : 'test-tool-call-id' ,
446+ input : {
447+ agent_type : agentType ,
448+ prompt,
449+ } ,
450+ } )
451+
452+ it ( 'should allow spawning inline agent when agent is in spawnableAgents list' , async ( ) => {
453+ const parentAgent = createMockAgent ( 'parent' , [ 'thinker' , 'reviewer' ] )
454+ const childAgent = createMockAgent ( 'thinker' )
455+ const ws = new MockWebSocket ( ) as unknown as WebSocket
456+ const sessionState = getInitialSessionState ( mockFileContext )
457+ const toolCall = createInlineSpawnToolCall ( 'thinker' )
458+
459+ const { result } = handleSpawnAgentInline ( {
460+ previousToolCallFinished : Promise . resolve ( ) ,
461+ toolCall,
462+ fileContext : mockFileContext ,
463+ clientSessionId : 'test-session' ,
464+ userInputId : 'test-input' ,
465+ getLatestState : ( ) => ( { messages : [ ] } ) ,
466+ state : {
467+ ws,
468+ fingerprintId : 'test-fingerprint' ,
469+ userId : TEST_USER_ID ,
470+ agentTemplate : parentAgent ,
471+ localAgentTemplates : { thinker : childAgent } ,
472+ messages : [ ] ,
473+ agentState : sessionState . mainAgentState ,
474+ } ,
475+ } )
476+
477+ await result // Should not throw
478+ expect ( mockLoopAgentSteps ) . toHaveBeenCalledTimes ( 1 )
479+ } )
480+
481+ it ( 'should reject spawning inline agent when agent is not in spawnableAgents list' , async ( ) => {
482+ const parentAgent = createMockAgent ( 'parent' , [ 'thinker' ] ) // Only allows thinker
483+ const childAgent = createMockAgent ( 'reviewer' )
484+ const ws = new MockWebSocket ( ) as unknown as WebSocket
485+ const sessionState = getInitialSessionState ( mockFileContext )
486+ const toolCall = createInlineSpawnToolCall ( 'reviewer' ) // Try to spawn reviewer
487+
488+ const { result } = handleSpawnAgentInline ( {
489+ previousToolCallFinished : Promise . resolve ( ) ,
490+ toolCall,
491+ fileContext : mockFileContext ,
492+ clientSessionId : 'test-session' ,
493+ userInputId : 'test-input' ,
494+ getLatestState : ( ) => ( { messages : [ ] } ) ,
495+ state : {
496+ ws,
497+ fingerprintId : 'test-fingerprint' ,
498+ userId : TEST_USER_ID ,
499+ agentTemplate : parentAgent ,
500+ localAgentTemplates : { reviewer : childAgent } ,
501+ messages : [ ] ,
502+ agentState : sessionState . mainAgentState ,
503+ } ,
504+ } )
505+
506+ await expect ( result ) . rejects . toThrow ( 'is not allowed to spawn child agent type reviewer' )
507+ expect ( mockLoopAgentSteps ) . not . toHaveBeenCalled ( )
508+ } )
509+
510+ it ( 'should reject spawning inline agent when agent template is not found' , async ( ) => {
511+ const parentAgent = createMockAgent ( 'parent' , [ 'nonexistent' ] )
512+ const ws = new MockWebSocket ( ) as unknown as WebSocket
513+ const sessionState = getInitialSessionState ( mockFileContext )
514+ const toolCall = createInlineSpawnToolCall ( 'nonexistent' )
515+
516+ const { result } = handleSpawnAgentInline ( {
517+ previousToolCallFinished : Promise . resolve ( ) ,
518+ toolCall,
519+ fileContext : mockFileContext ,
520+ clientSessionId : 'test-session' ,
521+ userInputId : 'test-input' ,
522+ getLatestState : ( ) => ( { messages : [ ] } ) ,
523+ state : {
524+ ws,
525+ fingerprintId : 'test-fingerprint' ,
526+ userId : TEST_USER_ID ,
527+ agentTemplate : parentAgent ,
528+ localAgentTemplates : { } , // Empty - agent not found
529+ messages : [ ] ,
530+ agentState : sessionState . mainAgentState ,
531+ } ,
532+ } )
533+
534+ await expect ( result ) . rejects . toThrow ( 'Agent type nonexistent not found' )
535+ expect ( mockLoopAgentSteps ) . not . toHaveBeenCalled ( )
536+ } )
537+
538+ it ( 'should handle versioned inline agent permissions correctly' , async ( ) => {
539+ const parentAgent = createMockAgent ( 'parent' , [ 'codebuff/thinker@1.0.0' ] )
540+ const childAgent = createMockAgent ( 'codebuff/thinker@1.0.0' )
541+ const ws = new MockWebSocket ( ) as unknown as WebSocket
542+ const sessionState = getInitialSessionState ( mockFileContext )
543+ const toolCall = createInlineSpawnToolCall ( 'codebuff/thinker@1.0.0' )
544+
545+ const { result } = handleSpawnAgentInline ( {
546+ previousToolCallFinished : Promise . resolve ( ) ,
547+ toolCall,
548+ fileContext : mockFileContext ,
549+ clientSessionId : 'test-session' ,
550+ userInputId : 'test-input' ,
551+ getLatestState : ( ) => ( { messages : [ ] } ) ,
552+ state : {
553+ ws,
554+ fingerprintId : 'test-fingerprint' ,
555+ userId : TEST_USER_ID ,
556+ agentTemplate : parentAgent ,
557+ localAgentTemplates : { 'codebuff/thinker@1.0.0' : childAgent } ,
558+ messages : [ ] ,
559+ agentState : sessionState . mainAgentState ,
560+ } ,
561+ } )
562+
563+ await result // Should not throw
564+ expect ( mockLoopAgentSteps ) . toHaveBeenCalledTimes ( 1 )
565+ } )
566+
567+ it ( 'should allow spawning simple agent name inline when parent allows versioned agent' , async ( ) => {
568+ const parentAgent = createMockAgent ( 'parent' , [ 'codebuff/thinker@1.0.0' ] )
569+ const childAgent = createMockAgent ( 'codebuff/thinker@1.0.0' )
570+ const ws = new MockWebSocket ( ) as unknown as WebSocket
571+ const sessionState = getInitialSessionState ( mockFileContext )
572+ const toolCall = createInlineSpawnToolCall ( 'thinker' ) // Simple name
573+
574+ const { result } = handleSpawnAgentInline ( {
575+ previousToolCallFinished : Promise . resolve ( ) ,
576+ toolCall,
577+ fileContext : mockFileContext ,
578+ clientSessionId : 'test-session' ,
579+ userInputId : 'test-input' ,
580+ getLatestState : ( ) => ( { messages : [ ] } ) ,
581+ state : {
582+ ws,
583+ fingerprintId : 'test-fingerprint' ,
584+ userId : TEST_USER_ID ,
585+ agentTemplate : parentAgent ,
586+ localAgentTemplates : {
587+ 'thinker' : childAgent ,
588+ 'codebuff/thinker@1.0.0' : childAgent , // Register with both keys
589+ } ,
590+ messages : [ ] ,
591+ agentState : sessionState . mainAgentState ,
592+ } ,
593+ } )
594+
595+ await result // Should not throw
596+ expect ( mockLoopAgentSteps ) . toHaveBeenCalledTimes ( 1 )
597+ } )
598+
599+ it ( 'should reject inline spawn when version mismatch exists' , async ( ) => {
600+ const parentAgent = createMockAgent ( 'parent' , [ 'codebuff/thinker@1.0.0' ] )
601+ const childAgent = createMockAgent ( 'codebuff/thinker@2.0.0' )
602+ const ws = new MockWebSocket ( ) as unknown as WebSocket
603+ const sessionState = getInitialSessionState ( mockFileContext )
604+ const toolCall = createInlineSpawnToolCall ( 'codebuff/thinker@2.0.0' )
605+
606+ const { result } = handleSpawnAgentInline ( {
607+ previousToolCallFinished : Promise . resolve ( ) ,
608+ toolCall,
609+ fileContext : mockFileContext ,
610+ clientSessionId : 'test-session' ,
611+ userInputId : 'test-input' ,
612+ getLatestState : ( ) => ( { messages : [ ] } ) ,
613+ state : {
614+ ws,
615+ fingerprintId : 'test-fingerprint' ,
616+ userId : TEST_USER_ID ,
617+ agentTemplate : parentAgent ,
618+ localAgentTemplates : { 'codebuff/thinker@2.0.0' : childAgent } ,
619+ messages : [ ] ,
620+ agentState : sessionState . mainAgentState ,
621+ } ,
622+ } )
623+
624+ await expect ( result ) . rejects . toThrow ( 'is not allowed to spawn child agent type' )
625+ expect ( mockLoopAgentSteps ) . not . toHaveBeenCalled ( )
626+ } )
627+
628+ it ( 'should validate required state parameters for inline spawn' , async ( ) => {
629+ const parentAgent = createMockAgent ( 'parent' , [ 'thinker' ] )
630+ const toolCall = createInlineSpawnToolCall ( 'thinker' )
631+
632+ expect ( ( ) => {
633+ handleSpawnAgentInline ( {
634+ previousToolCallFinished : Promise . resolve ( ) ,
635+ toolCall,
636+ fileContext : mockFileContext ,
637+ clientSessionId : 'test-session' ,
638+ userInputId : 'test-input' ,
639+ getLatestState : ( ) => ( { messages : [ ] } ) ,
640+ state : {
641+ // Missing required fields like ws, fingerprintId, etc.
642+ agentTemplate : parentAgent ,
643+ localAgentTemplates : { } ,
644+ } ,
645+ } )
646+ } ) . toThrow ( 'Missing WebSocket in state' )
647+ expect ( mockLoopAgentSteps ) . not . toHaveBeenCalled ( )
648+ } )
649+ } )
439650} )
0 commit comments