Skip to content

Commit bc4178a

Browse files
committed
Refactor CLI error messages for clarity; enhance expression evaluator tests; update input schema to use enum; improve LLM executor tests and local client handling; modify human step confirmation prompt; add error handling tests for mermaid rendering
1 parent e20d25d commit bc4178a

8 files changed

Lines changed: 266 additions & 93 deletions

File tree

src/cli.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -527,10 +527,7 @@ mcp
527527
console.log('1. Visit the following URL to authorize:');
528528
console.log(` ${authUrl}`);
529529
console.log(
530-
'\n Note: If you get a 500 error, it is because the Atlassian remote server requires a registered Client ID.'
531-
);
532-
console.log(
533-
' For CLI usage, it is recommended to use the local "mcp-atlassian" server instead.'
530+
'\n Note: If you encounter errors, ensure the server is correctly configured and accessible.'
534531
);
535532
console.log(' You can still manually provide an OAuth token below if you have one.');
536533
console.log('\n2. Paste the access token below:\n');

src/expression/evaluator.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ describe('ExpressionEvaluator', () => {
5959
expect(ExpressionEvaluator.evaluate('${{ false && 1 }}', context)).toBe(false);
6060
expect(ExpressionEvaluator.evaluate('${{ true || 1 }}', context)).toBe(true);
6161
expect(ExpressionEvaluator.evaluate('${{ false || 1 }}', context)).toBe(1);
62+
// Explicit short-circuit tests
63+
expect(ExpressionEvaluator.evaluate('${{ false && undefined_var }}', context)).toBe(false);
64+
expect(ExpressionEvaluator.evaluate('${{ true || undefined_var }}', context)).toBe(true);
65+
expect(ExpressionEvaluator.evaluate('${{ true && 2 }}', context)).toBe(2);
6266
});
6367

6468
test('should support comparison operators', () => {

src/parser/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { z } from 'zod';
33
// ===== Input/Output Schema =====
44

55
const InputSchema = z.object({
6-
type: z.string(),
6+
type: z.enum(['string', 'number', 'boolean', 'array', 'object']),
77
default: z.any().optional(),
88
description: z.string().optional(),
99
});

src/runner/llm-executor.test.ts

Lines changed: 174 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
214
import { mkdirSync, writeFileSync } from 'node:fs';
315
import { join } from 'node:path';
16+
import { Readable, Writable } from 'node:stream';
417
import type { ExpressionContext } from '../expression/evaluator';
518
import type { LlmStep, Step } from '../parser/schema';
619
import { ConfigLoader } from '../utils/config-loader';
@@ -24,8 +37,113 @@ const originalAnthropicChat = AnthropicAdapter.prototype.chat;
2437

2538
describe('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
---
41159
You 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
});

src/runner/llm-executor.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,12 @@ export async function executeLlmStep(
111111
continue;
112112
}
113113
logger.log(` 🔌 Connecting to MCP server: ${server.name}`);
114-
client = new MCPClient(server.command, server.args, server.env);
115114
try {
115+
client = await MCPClient.createLocal(
116+
server.command,
117+
server.args || [],
118+
server.env || {}
119+
);
116120
await client.initialize();
117121
localMcpClients.push(client);
118122
} catch (error) {

0 commit comments

Comments
 (0)