keystone-cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,19 @@
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';
2
14
  import { mkdirSync, writeFileSync } from 'node:fs';
3
15
  import { join } from 'node:path';
16
+ import { Readable, Writable } from 'node:stream';
4
17
  import type { ExpressionContext } from '../expression/evaluator';
5
18
  import type { LlmStep, Step } from '../parser/schema';
6
19
  import { ConfigLoader } from '../utils/config-loader';
@@ -24,8 +37,113 @@ const originalAnthropicChat = AnthropicAdapter.prototype.chat;
24
37
 
25
38
  describe('llm-executor', () => {
26
39
  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
+ };
27
131
 
28
132
  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
+
29
147
  try {
30
148
  mkdirSync(agentsDir, { recursive: true });
31
149
  } catch (e) {}
@@ -40,68 +158,35 @@ tools:
40
158
  ---
41
159
  You are a test agent.`;
42
160
  writeFileSync(join(agentsDir, 'test-agent.md'), agentContent);
161
+ });
43
162
 
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
96
174
  OpenAIAdapter.prototype.chat = mock(mockChat) as unknown as typeof originalOpenAIChat;
97
175
  CopilotAdapter.prototype.chat = mock(mockChat) as unknown as typeof originalCopilotChat;
98
176
  AnthropicAdapter.prototype.chat = mock(mockChat) as unknown as typeof originalAnthropicChat;
99
177
  });
100
178
 
179
+ afterEach(() => {
180
+ initSpy.mockRestore();
181
+ listToolsSpy.mockRestore();
182
+ stopSpy.mockRestore();
183
+ });
184
+
101
185
  afterAll(() => {
102
186
  OpenAIAdapter.prototype.chat = originalOpenAIChat;
103
187
  CopilotAdapter.prototype.chat = originalCopilotChat;
104
188
  AnthropicAdapter.prototype.chat = originalAnthropicChat;
189
+ spawnSpy.mockRestore();
105
190
  });
106
191
 
107
192
  it('should execute a simple LLM step', async () => {
@@ -279,9 +364,12 @@ You are a test agent.`;
279
364
  const context: ExpressionContext = { inputs: {}, steps: {} };
280
365
  const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
281
366
 
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
+ });
285
373
  const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
286
374
 
287
375
  await executeLlmStep(
@@ -293,7 +381,7 @@ You are a test agent.`;
293
381
  expect(consoleSpy).toHaveBeenCalledWith(
294
382
  expect.stringContaining('Failed to connect to MCP server fail-mcp')
295
383
  );
296
- spy.mockRestore();
384
+ createLocalSpy.mockRestore();
297
385
  consoleSpy.mockRestore();
298
386
  });
299
387
 
@@ -309,13 +397,14 @@ You are a test agent.`;
309
397
  const context: ExpressionContext = { inputs: {}, steps: {} };
310
398
  const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
311
399
 
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
+ });
319
408
 
320
409
  const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
321
410
  const originalCopilotChatInner = CopilotAdapter.prototype.chat;
@@ -351,11 +440,7 @@ You are a test agent.`;
351
440
  expect(toolErrorCaptured).toBe(true);
352
441
 
353
442
  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();
359
444
  });
360
445
 
361
446
  it('should use global MCP servers when useGlobalMcp is true', async () => {
@@ -382,10 +467,15 @@ You are a test agent.`;
382
467
  const context: ExpressionContext = { inputs: {}, steps: {} };
383
468
  const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
384
469
 
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
+ });
389
479
 
390
480
  let toolFound = false;
391
481
  const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
@@ -409,8 +499,7 @@ You are a test agent.`;
409
499
  expect(toolFound).toBe(true);
410
500
 
411
501
  OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
412
- initSpy.mockRestore();
413
- listSpy.mockRestore();
502
+ createLocalSpy.mockRestore();
414
503
  ConfigLoader.clear();
415
504
  });
416
505
 
@@ -502,8 +591,13 @@ You are a test agent.`;
502
591
  const context: ExpressionContext = { inputs: {}, steps: {} };
503
592
  const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
504
593
 
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
+ });
507
601
 
508
602
  const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
509
603
  const mockChat = mock(async () => ({
@@ -526,12 +620,11 @@ You are a test agent.`;
526
620
  // We can check this by seeing how many times initialize was called if they were different,
527
621
  // but here we just want to ensure it didn't push the global one again.
528
622
 
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);
531
625
 
532
626
  OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
533
- initSpy.mockRestore();
534
- listSpy.mockRestore();
627
+ createLocalSpy.mockRestore();
535
628
  managerSpy.mockRestore();
536
629
  ConfigLoader.clear();
537
630
  });
@@ -23,9 +23,10 @@ export async function executeLlmStep(
23
23
  context: ExpressionContext,
24
24
  executeStepFn: (step: Step, context: ExpressionContext) => Promise<StepResult>,
25
25
  logger: Logger = console,
26
- mcpManager?: MCPManager
26
+ mcpManager?: MCPManager,
27
+ workflowDir?: string
27
28
  ): Promise<StepResult> {
28
- const agentPath = resolveAgentPath(step.agent);
29
+ const agentPath = resolveAgentPath(step.agent, workflowDir);
29
30
  const agent = parseAgent(agentPath);
30
31
 
31
32
  const provider = step.provider || agent.provider;
@@ -110,8 +111,12 @@ export async function executeLlmStep(
110
111
  continue;
111
112
  }
112
113
  logger.log(` 🔌 Connecting to MCP server: ${server.name}`);
113
- client = new MCPClient(server.command, server.args, server.env);
114
114
  try {
115
+ client = await MCPClient.createLocal(
116
+ server.command,
117
+ server.args || [],
118
+ server.env || {}
119
+ );
115
120
  await client.initialize();
116
121
  localMcpClients.push(client);
117
122
  } catch (error) {
@@ -124,77 +124,115 @@ describe('MCPClient', () => {
124
124
 
125
125
  describe('SSE Transport', () => {
126
126
  it('should connect and receive endpoint', async () => {
127
- const mockEventSource = new EventEmitter();
128
- // @ts-ignore
129
- mockEventSource.addEventListener = mockEventSource.on;
130
- // @ts-ignore
131
- mockEventSource.close = mock(() => {});
132
- // @ts-ignore
133
- global.EventSource = mock(() => mockEventSource);
127
+ let controller: ReadableStreamDefaultController;
128
+ const stream = new ReadableStream({
129
+ start(c) {
130
+ controller = c;
131
+ controller.enqueue(new TextEncoder().encode('event: endpoint\ndata: /endpoint\n\n'));
132
+ },
133
+ });
134
134
 
135
- const clientPromise = MCPClient.createRemote('http://localhost:8080/sse');
135
+ const fetchMock = spyOn(global, 'fetch').mockImplementation((url) => {
136
+ if (url === 'http://localhost:8080/sse') {
137
+ return Promise.resolve(new Response(stream));
138
+ }
139
+ return Promise.resolve(new Response(JSON.stringify({ ok: true })));
140
+ });
136
141
 
137
- // Simulate endpoint event
138
- mockEventSource.emit('endpoint', { data: '/endpoint' });
142
+ const clientPromise = MCPClient.createRemote('http://localhost:8080/sse');
139
143
 
140
144
  const client = await clientPromise;
141
145
  expect(client).toBeDefined();
142
146
 
143
- // Test sending a message
144
- const fetchMock = spyOn(global, 'fetch').mockImplementation(() =>
145
- Promise.resolve(new Response(JSON.stringify({ ok: true })))
146
- );
147
-
148
147
  const initPromise = client.initialize();
149
148
 
150
149
  // Simulate message event (response from server)
151
- mockEventSource.emit('message', {
152
- data: JSON.stringify({
153
- jsonrpc: '2.0',
154
- id: 0,
155
- result: { protocolVersion: '2024-11-05' },
156
- }),
157
- });
150
+ if (controller) {
151
+ controller.enqueue(
152
+ new TextEncoder().encode(
153
+ `data: ${JSON.stringify({
154
+ jsonrpc: '2.0',
155
+ id: 0,
156
+ result: { protocolVersion: '2024-11-05' },
157
+ })}\n\n`
158
+ )
159
+ );
160
+ }
158
161
 
159
162
  const response = await initPromise;
160
163
  expect(response.result?.protocolVersion).toBe('2024-11-05');
161
164
  expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/endpoint', expect.any(Object));
162
165
 
163
166
  client.stop();
164
- // @ts-ignore
165
- expect(mockEventSource.close).toHaveBeenCalled();
167
+ fetchMock.mockRestore();
168
+ });
166
169
 
170
+ it('should handle SSE with multiple events and chunked data', async () => {
171
+ let controller: ReadableStreamDefaultController;
172
+ const stream = new ReadableStream({
173
+ start(c) {
174
+ controller = c;
175
+ // Send endpoint event
176
+ controller.enqueue(new TextEncoder().encode('event: endpoint\n'));
177
+ controller.enqueue(new TextEncoder().encode('data: /endpoint\n\n'));
178
+ },
179
+ });
180
+
181
+ const fetchMock = spyOn(global, 'fetch').mockImplementation((url) => {
182
+ if (url === 'http://localhost:8080/sse') {
183
+ return Promise.resolve(new Response(stream));
184
+ }
185
+ return Promise.resolve(new Response(JSON.stringify({ ok: true })));
186
+ });
187
+
188
+ const client = await MCPClient.createRemote('http://localhost:8080/sse');
189
+
190
+ // We can't easily hook into onMessage without reaching into internals
191
+ // Instead, we'll test that initialize resolves correctly when the response arrives
192
+ const initPromise = client.initialize();
193
+
194
+ // Enqueue data in chunks
195
+ if (controller) {
196
+ controller.enqueue(new TextEncoder().encode('data: {"jsonrpc":"2.0","id":0,'));
197
+ controller.enqueue(
198
+ new TextEncoder().encode('"result":{"protocolVersion":"2024-11-05"}}\n\n')
199
+ );
200
+
201
+ // Send another event (just to test dispatching doesn't crash)
202
+ controller.enqueue(
203
+ new TextEncoder().encode(
204
+ 'event: message\ndata: {"jsonrpc":"2.0","id":99,"result":"ignored"}\n\n'
205
+ )
206
+ );
207
+
208
+ // Send empty line
209
+ controller.enqueue(new TextEncoder().encode('\n'));
210
+
211
+ controller.close();
212
+ }
213
+
214
+ const response = await initPromise;
215
+ expect(response.result?.protocolVersion).toBe('2024-11-05');
216
+
217
+ client.stop();
167
218
  fetchMock.mockRestore();
168
- // @ts-ignore
169
- global.EventSource = undefined;
170
219
  });
171
220
 
172
221
  it('should handle SSE connection failure', async () => {
173
- const mockEventSource = {
174
- addEventListener: mock((_event: string, callback: (arg: unknown) => void) => {
175
- if (_event === 'error') {
176
- // Store the callback to trigger it later
177
- mockEventSource.onerror = callback;
178
- }
179
- }),
180
- onerror: null as ((arg: unknown) => void) | null,
181
- close: mock(() => {}),
182
- };
183
-
184
- // @ts-ignore
185
- global.EventSource = mock(() => mockEventSource);
222
+ const fetchMock = spyOn(global, 'fetch').mockImplementation(() =>
223
+ Promise.resolve(
224
+ new Response(null, {
225
+ status: 500,
226
+ statusText: 'Internal Server Error',
227
+ })
228
+ )
229
+ );
186
230
 
187
231
  const clientPromise = MCPClient.createRemote('http://localhost:8080/sse');
188
232
 
189
- // Trigger the onerror callback
190
- if (mockEventSource.onerror) {
191
- mockEventSource.onerror({ message: 'Connection failed' });
192
- }
193
-
194
- await expect(clientPromise).rejects.toThrow(/SSE connection failed/);
233
+ await expect(clientPromise).rejects.toThrow(/SSE connection failed: 500/);
195
234
 
196
- // @ts-ignore
197
- global.EventSource = undefined;
235
+ fetchMock.mockRestore();
198
236
  });
199
237
  });
200
238
  });