1- import { afterAll , beforeAll , describe , expect , it , mock , spyOn } from 'bun:test' ;
1+ import {
2+ afterAll ,
3+ afterEach ,
4+ beforeAll ,
5+ beforeEach ,
6+ describe ,
7+ expect ,
8+ it ,
9+ mock ,
10+ spyOn ,
11+ } from 'bun:test' ;
12+ import * as child_process from 'node:child_process' ;
13+ import { EventEmitter } from 'node:events' ;
214import { mkdirSync , writeFileSync } from 'node:fs' ;
315import { join } from 'node:path' ;
16+ import { Readable , Writable } from 'node:stream' ;
417import type { ExpressionContext } from '../expression/evaluator' ;
518import type { LlmStep , Step } from '../parser/schema' ;
619import { ConfigLoader } from '../utils/config-loader' ;
@@ -24,8 +37,113 @@ const originalAnthropicChat = AnthropicAdapter.prototype.chat;
2437
2538describe ( 'llm-executor' , ( ) => {
2639 const agentsDir = join ( process . cwd ( ) , '.keystone' , 'workflows' , 'agents' ) ;
40+ let spawnSpy : ReturnType < typeof spyOn > ;
41+ let initSpy : ReturnType < typeof spyOn > ;
42+ let listToolsSpy : ReturnType < typeof spyOn > ;
43+ let stopSpy : ReturnType < typeof spyOn > ;
44+
45+ const mockChat = async ( messages : unknown [ ] , _options ?: unknown ) => {
46+ const msgs = messages as LLMMessage [ ] ;
47+ const lastMessage = msgs [ msgs . length - 1 ] ;
48+ const systemMessage = msgs . find ( ( m ) => m . role === 'system' ) ;
49+
50+ // If there's any tool message, just respond with final message
51+ if ( msgs . some ( ( m ) => m . role === 'tool' ) ) {
52+ return {
53+ message : { role : 'assistant' , content : 'LLM Response' } ,
54+ } ;
55+ }
56+
57+ if ( systemMessage ?. content ?. includes ( 'IMPORTANT: You must output valid JSON' ) ) {
58+ return {
59+ message : { role : 'assistant' , content : '```json\n{"foo": "bar"}\n```' } ,
60+ } ;
61+ }
62+
63+ if ( lastMessage . role === 'user' && lastMessage . content ?. includes ( 'trigger tool' ) ) {
64+ return {
65+ message : {
66+ role : 'assistant' ,
67+ content : null ,
68+ tool_calls : [
69+ {
70+ id : 'call-1' ,
71+ type : 'function' ,
72+ function : { name : 'test-tool' , arguments : '{"val": 123}' } ,
73+ } ,
74+ ] ,
75+ } ,
76+ } ;
77+ }
78+
79+ if ( lastMessage . role === 'user' && lastMessage . content ?. includes ( 'trigger adhoc tool' ) ) {
80+ return {
81+ message : {
82+ role : 'assistant' ,
83+ content : null ,
84+ tool_calls : [
85+ {
86+ id : 'call-adhoc' ,
87+ type : 'function' ,
88+ function : { name : 'adhoc-tool' , arguments : '{}' } ,
89+ } ,
90+ ] ,
91+ } ,
92+ } ;
93+ }
94+
95+ if ( lastMessage . role === 'user' && lastMessage . content ?. includes ( 'trigger unknown tool' ) ) {
96+ return {
97+ message : {
98+ role : 'assistant' ,
99+ content : null ,
100+ tool_calls : [
101+ {
102+ id : 'call-unknown' ,
103+ type : 'function' ,
104+ function : { name : 'unknown-tool' , arguments : '{}' } ,
105+ } ,
106+ ] ,
107+ } ,
108+ } ;
109+ }
110+
111+ if ( lastMessage . role === 'user' && lastMessage . content ?. includes ( 'trigger mcp tool' ) ) {
112+ return {
113+ message : {
114+ role : 'assistant' ,
115+ content : null ,
116+ tool_calls : [
117+ {
118+ id : 'call-mcp' ,
119+ type : 'function' ,
120+ function : { name : 'mcp-tool' , arguments : '{}' } ,
121+ } ,
122+ ] ,
123+ } ,
124+ } ;
125+ }
126+
127+ return {
128+ message : { role : 'assistant' , content : 'LLM Response' } ,
129+ } ;
130+ } ;
27131
28132 beforeAll ( ( ) => {
133+ // Mock spawn to avoid actual process creation
134+ const mockProcess = Object . assign ( new EventEmitter ( ) , {
135+ stdout : new Readable ( { read ( ) { } } ) ,
136+ stdin : new Writable ( {
137+ write ( _chunk , _encoding , cb : ( error ?: Error | null ) => void ) {
138+ cb ( ) ;
139+ } ,
140+ } ) ,
141+ kill : mock ( ( ) => { } ) ,
142+ } ) ;
143+ spawnSpy = spyOn ( child_process , 'spawn' ) . mockReturnValue (
144+ mockProcess as unknown as child_process . ChildProcess
145+ ) ;
146+
29147 try {
30148 mkdirSync ( agentsDir , { recursive : true } ) ;
31149 } catch ( e ) { }
@@ -40,68 +158,35 @@ tools:
40158---
41159You are a test agent.` ;
42160 writeFileSync ( join ( agentsDir , 'test-agent.md' ) , agentContent ) ;
161+ } ) ;
43162
44- const mockChat = async ( messages : unknown [ ] , _options ?: unknown ) => {
45- const lastMessage = messages [ messages . length - 1 ] as { content ?: string } ;
46- const systemMessage = messages . find (
47- ( m ) =>
48- typeof m === 'object' &&
49- m !== null &&
50- 'role' in m &&
51- ( m as { role : string } ) . role === 'system'
52- ) as { content ?: string } | undefined ;
53-
54- if ( systemMessage ?. content ?. includes ( 'IMPORTANT: You must output valid JSON' ) ) {
55- return {
56- message : { role : 'assistant' , content : '```json\n{"foo": "bar"}\n```' } ,
57- } ;
58- }
59-
60- if ( lastMessage ?. content ?. includes ( 'trigger tool' ) ) {
61- return {
62- message : {
63- role : 'assistant' ,
64- content : null ,
65- tool_calls : [
66- {
67- id : 'call-1' ,
68- type : 'function' ,
69- function : { name : 'test-tool' , arguments : '{"val": 123}' } ,
70- } ,
71- ] ,
72- } ,
73- } ;
74- }
75-
76- if ( lastMessage ?. content ?. includes ( 'trigger adhoc tool' ) ) {
77- return {
78- message : {
79- role : 'assistant' ,
80- content : null ,
81- tool_calls : [
82- {
83- id : 'call-adhoc' ,
84- type : 'function' ,
85- function : { name : 'adhoc-tool' , arguments : '{}' } ,
86- } ,
87- ] ,
88- } ,
89- } ;
90- }
91- return {
92- message : { role : 'assistant' , content : 'LLM Response' } ,
93- } ;
94- } ;
95-
163+ beforeEach ( ( ) => {
164+ // Global MCP mocks to avoid hangs
165+ initSpy = spyOn ( MCPClient . prototype , 'initialize' ) . mockResolvedValue ( {
166+ jsonrpc : '2.0' ,
167+ id : 0 ,
168+ result : { protocolVersion : '2024-11-05' } ,
169+ } as MCPResponse ) ;
170+ listToolsSpy = spyOn ( MCPClient . prototype , 'listTools' ) . mockResolvedValue ( [ ] ) ;
171+ stopSpy = spyOn ( MCPClient . prototype , 'stop' ) . mockReturnValue ( undefined ) ;
172+
173+ // Set adapters to global mock
96174 OpenAIAdapter . prototype . chat = mock ( mockChat ) as unknown as typeof originalOpenAIChat ;
97175 CopilotAdapter . prototype . chat = mock ( mockChat ) as unknown as typeof originalCopilotChat ;
98176 AnthropicAdapter . prototype . chat = mock ( mockChat ) as unknown as typeof originalAnthropicChat ;
99177 } ) ;
100178
179+ afterEach ( ( ) => {
180+ initSpy . mockRestore ( ) ;
181+ listToolsSpy . mockRestore ( ) ;
182+ stopSpy . mockRestore ( ) ;
183+ } ) ;
184+
101185 afterAll ( ( ) => {
102186 OpenAIAdapter . prototype . chat = originalOpenAIChat ;
103187 CopilotAdapter . prototype . chat = originalCopilotChat ;
104188 AnthropicAdapter . prototype . chat = originalAnthropicChat ;
189+ spawnSpy . mockRestore ( ) ;
105190 } ) ;
106191
107192 it ( 'should execute a simple LLM step' , async ( ) => {
@@ -279,9 +364,12 @@ You are a test agent.`;
279364 const context : ExpressionContext = { inputs : { } , steps : { } } ;
280365 const executeStepFn = mock ( async ( ) => ( { status : 'success' as const , output : 'ok' } ) ) ;
281366
282- const spy = spyOn ( MCPClient . prototype , 'initialize' ) . mockRejectedValue (
283- new Error ( 'Connect failed' )
284- ) ;
367+ const createLocalSpy = spyOn ( MCPClient , 'createLocal' ) . mockImplementation ( async ( ) => {
368+ const client = Object . create ( MCPClient . prototype ) ;
369+ spyOn ( client , 'initialize' ) . mockRejectedValue ( new Error ( 'Connect failed' ) ) ;
370+ spyOn ( client , 'stop' ) . mockReturnValue ( undefined ) ;
371+ return client ;
372+ } ) ;
285373 const consoleSpy = spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } ) ;
286374
287375 await executeLlmStep (
@@ -293,7 +381,7 @@ You are a test agent.`;
293381 expect ( consoleSpy ) . toHaveBeenCalledWith (
294382 expect . stringContaining ( 'Failed to connect to MCP server fail-mcp' )
295383 ) ;
296- spy . mockRestore ( ) ;
384+ createLocalSpy . mockRestore ( ) ;
297385 consoleSpy . mockRestore ( ) ;
298386 } ) ;
299387
@@ -309,13 +397,14 @@ You are a test agent.`;
309397 const context : ExpressionContext = { inputs : { } , steps : { } } ;
310398 const executeStepFn = mock ( async ( ) => ( { status : 'success' as const , output : 'ok' } ) ) ;
311399
312- const initSpy = spyOn ( MCPClient . prototype , 'initialize' ) . mockResolvedValue ( { } as MCPResponse ) ;
313- const listSpy = spyOn ( MCPClient . prototype , 'listTools' ) . mockResolvedValue ( [
314- { name : 'mcp-tool' , inputSchema : { } } ,
315- ] ) ;
316- const callSpy = spyOn ( MCPClient . prototype , 'callTool' ) . mockRejectedValue (
317- new Error ( 'Tool failed' )
318- ) ;
400+ const createLocalSpy = spyOn ( MCPClient , 'createLocal' ) . mockImplementation ( async ( ) => {
401+ const client = Object . create ( MCPClient . prototype ) ;
402+ spyOn ( client , 'initialize' ) . mockResolvedValue ( { } as MCPResponse ) ;
403+ spyOn ( client , 'listTools' ) . mockResolvedValue ( [ { name : 'mcp-tool' , inputSchema : { } } ] ) ;
404+ spyOn ( client , 'callTool' ) . mockRejectedValue ( new Error ( 'Tool failed' ) ) ;
405+ spyOn ( client , 'stop' ) . mockReturnValue ( undefined ) ;
406+ return client ;
407+ } ) ;
319408
320409 const originalOpenAIChatInner = OpenAIAdapter . prototype . chat ;
321410 const originalCopilotChatInner = CopilotAdapter . prototype . chat ;
@@ -351,11 +440,7 @@ You are a test agent.`;
351440 expect ( toolErrorCaptured ) . toBe ( true ) ;
352441
353442 OpenAIAdapter . prototype . chat = originalOpenAIChatInner ;
354- CopilotAdapter . prototype . chat = originalCopilotChatInner ;
355- AnthropicAdapter . prototype . chat = originalAnthropicChatInner ;
356- initSpy . mockRestore ( ) ;
357- listSpy . mockRestore ( ) ;
358- callSpy . mockRestore ( ) ;
443+ createLocalSpy . mockRestore ( ) ;
359444 } ) ;
360445
361446 it ( 'should use global MCP servers when useGlobalMcp is true' , async ( ) => {
@@ -382,10 +467,15 @@ You are a test agent.`;
382467 const context : ExpressionContext = { inputs : { } , steps : { } } ;
383468 const executeStepFn = mock ( async ( ) => ( { status : 'success' as const , output : 'ok' } ) ) ;
384469
385- const initSpy = spyOn ( MCPClient . prototype , 'initialize' ) . mockResolvedValue ( { } as MCPResponse ) ;
386- const listSpy = spyOn ( MCPClient . prototype , 'listTools' ) . mockResolvedValue ( [
387- { name : 'global-tool' , description : 'A global tool' , inputSchema : { } } ,
388- ] ) ;
470+ const createLocalSpy = spyOn ( MCPClient , 'createLocal' ) . mockImplementation ( async ( ) => {
471+ const client = Object . create ( MCPClient . prototype ) ;
472+ spyOn ( client , 'initialize' ) . mockResolvedValue ( { } as MCPResponse ) ;
473+ spyOn ( client , 'listTools' ) . mockResolvedValue ( [
474+ { name : 'global-tool' , description : 'A global tool' , inputSchema : { } } ,
475+ ] ) ;
476+ spyOn ( client , 'stop' ) . mockReturnValue ( undefined ) ;
477+ return client ;
478+ } ) ;
389479
390480 let toolFound = false ;
391481 const originalOpenAIChatInner = OpenAIAdapter . prototype . chat ;
@@ -409,8 +499,7 @@ You are a test agent.`;
409499 expect ( toolFound ) . toBe ( true ) ;
410500
411501 OpenAIAdapter . prototype . chat = originalOpenAIChatInner ;
412- initSpy . mockRestore ( ) ;
413- listSpy . mockRestore ( ) ;
502+ createLocalSpy . mockRestore ( ) ;
414503 ConfigLoader . clear ( ) ;
415504 } ) ;
416505
@@ -502,8 +591,13 @@ You are a test agent.`;
502591 const context : ExpressionContext = { inputs : { } , steps : { } } ;
503592 const executeStepFn = mock ( async ( ) => ( { status : 'success' as const , output : 'ok' } ) ) ;
504593
505- const initSpy = spyOn ( MCPClient . prototype , 'initialize' ) . mockResolvedValue ( { } as MCPResponse ) ;
506- const listSpy = spyOn ( MCPClient . prototype , 'listTools' ) . mockResolvedValue ( [ ] ) ;
594+ const createLocalSpy = spyOn ( MCPClient , 'createLocal' ) . mockImplementation ( async ( ) => {
595+ const client = Object . create ( MCPClient . prototype ) ;
596+ spyOn ( client , 'initialize' ) . mockResolvedValue ( { } as MCPResponse ) ;
597+ spyOn ( client , 'listTools' ) . mockResolvedValue ( [ ] ) ;
598+ spyOn ( client , 'stop' ) . mockReturnValue ( undefined ) ;
599+ return client ;
600+ } ) ;
507601
508602 const originalOpenAIChatInner = OpenAIAdapter . prototype . chat ;
509603 const mockChat = mock ( async ( ) => ( {
@@ -526,12 +620,11 @@ You are a test agent.`;
526620 // We can check this by seeing how many times initialize was called if they were different,
527621 // but here we just want to ensure it didn't push the global one again.
528622
529- // Actually, initialize will be called for 'test-mcp' (explicitly listed)
530- expect ( initSpy ) . toHaveBeenCalledTimes ( 1 ) ;
623+ // Actually, createLocal will be called for 'test-mcp' (explicitly listed)
624+ expect ( createLocalSpy ) . toHaveBeenCalledTimes ( 1 ) ;
531625
532626 OpenAIAdapter . prototype . chat = originalOpenAIChatInner ;
533- initSpy . mockRestore ( ) ;
534- listSpy . mockRestore ( ) ;
627+ createLocalSpy . mockRestore ( ) ;
535628 managerSpy . mockRestore ( ) ;
536629 ConfigLoader . clear ( ) ;
537630 } ) ;
0 commit comments