keystone-cli 0.1.0
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.
- package/README.md +136 -0
- package/logo.png +0 -0
- package/package.json +45 -0
- package/src/cli.ts +775 -0
- package/src/db/workflow-db.test.ts +99 -0
- package/src/db/workflow-db.ts +265 -0
- package/src/expression/evaluator.test.ts +247 -0
- package/src/expression/evaluator.ts +517 -0
- package/src/parser/agent-parser.test.ts +123 -0
- package/src/parser/agent-parser.ts +59 -0
- package/src/parser/config-schema.ts +54 -0
- package/src/parser/schema.ts +157 -0
- package/src/parser/workflow-parser.test.ts +212 -0
- package/src/parser/workflow-parser.ts +228 -0
- package/src/runner/llm-adapter.test.ts +329 -0
- package/src/runner/llm-adapter.ts +306 -0
- package/src/runner/llm-executor.test.ts +537 -0
- package/src/runner/llm-executor.ts +256 -0
- package/src/runner/mcp-client.test.ts +122 -0
- package/src/runner/mcp-client.ts +123 -0
- package/src/runner/mcp-manager.test.ts +143 -0
- package/src/runner/mcp-manager.ts +85 -0
- package/src/runner/mcp-server.test.ts +242 -0
- package/src/runner/mcp-server.ts +436 -0
- package/src/runner/retry.test.ts +52 -0
- package/src/runner/retry.ts +58 -0
- package/src/runner/shell-executor.test.ts +123 -0
- package/src/runner/shell-executor.ts +166 -0
- package/src/runner/step-executor.test.ts +465 -0
- package/src/runner/step-executor.ts +354 -0
- package/src/runner/timeout.test.ts +20 -0
- package/src/runner/timeout.ts +30 -0
- package/src/runner/tool-integration.test.ts +198 -0
- package/src/runner/workflow-runner.test.ts +358 -0
- package/src/runner/workflow-runner.ts +955 -0
- package/src/ui/dashboard.tsx +165 -0
- package/src/utils/auth-manager.test.ts +152 -0
- package/src/utils/auth-manager.ts +88 -0
- package/src/utils/config-loader.test.ts +52 -0
- package/src/utils/config-loader.ts +85 -0
- package/src/utils/mermaid.test.ts +51 -0
- package/src/utils/mermaid.ts +87 -0
- package/src/utils/redactor.test.ts +66 -0
- package/src/utils/redactor.ts +60 -0
- package/src/utils/workflow-registry.test.ts +108 -0
- package/src/utils/workflow-registry.ts +121 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import type { ExpressionContext } from '../expression/evaluator.ts';
|
|
2
|
+
import { ExpressionEvaluator } from '../expression/evaluator.ts';
|
|
3
|
+
// Removed synchronous file I/O imports - using Bun's async file API instead
|
|
4
|
+
import type {
|
|
5
|
+
FileStep,
|
|
6
|
+
HumanStep,
|
|
7
|
+
RequestStep,
|
|
8
|
+
ShellStep,
|
|
9
|
+
SleepStep,
|
|
10
|
+
Step,
|
|
11
|
+
WorkflowStep,
|
|
12
|
+
} from '../parser/schema.ts';
|
|
13
|
+
import { executeShell } from './shell-executor.ts';
|
|
14
|
+
import type { Logger } from './workflow-runner.ts';
|
|
15
|
+
|
|
16
|
+
import * as readline from 'node:readline/promises';
|
|
17
|
+
import { executeLlmStep } from './llm-executor.ts';
|
|
18
|
+
import type { MCPManager } from './mcp-manager.ts';
|
|
19
|
+
|
|
20
|
+
export class WorkflowSuspendedError extends Error {
|
|
21
|
+
constructor(
|
|
22
|
+
public readonly message: string,
|
|
23
|
+
public readonly stepId: string,
|
|
24
|
+
public readonly inputType: 'confirm' | 'text'
|
|
25
|
+
) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'WorkflowSuspendedError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StepResult {
|
|
32
|
+
output: unknown;
|
|
33
|
+
status: 'success' | 'failed' | 'suspended';
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Execute a single step based on its type
|
|
39
|
+
*/
|
|
40
|
+
export async function executeStep(
|
|
41
|
+
step: Step,
|
|
42
|
+
context: ExpressionContext,
|
|
43
|
+
logger: Logger = console,
|
|
44
|
+
executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>,
|
|
45
|
+
mcpManager?: MCPManager
|
|
46
|
+
): Promise<StepResult> {
|
|
47
|
+
try {
|
|
48
|
+
let result: StepResult;
|
|
49
|
+
switch (step.type) {
|
|
50
|
+
case 'shell':
|
|
51
|
+
result = await executeShellStep(step, context, logger);
|
|
52
|
+
break;
|
|
53
|
+
case 'file':
|
|
54
|
+
result = await executeFileStep(step, context, logger);
|
|
55
|
+
break;
|
|
56
|
+
case 'request':
|
|
57
|
+
result = await executeRequestStep(step, context, logger);
|
|
58
|
+
break;
|
|
59
|
+
case 'human':
|
|
60
|
+
result = await executeHumanStep(step, context, logger);
|
|
61
|
+
break;
|
|
62
|
+
case 'sleep':
|
|
63
|
+
result = await executeSleepStep(step, context, logger);
|
|
64
|
+
break;
|
|
65
|
+
case 'llm':
|
|
66
|
+
result = await executeLlmStep(
|
|
67
|
+
step,
|
|
68
|
+
context,
|
|
69
|
+
(s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager),
|
|
70
|
+
logger,
|
|
71
|
+
mcpManager
|
|
72
|
+
);
|
|
73
|
+
break;
|
|
74
|
+
case 'workflow':
|
|
75
|
+
if (!executeWorkflowFn) {
|
|
76
|
+
throw new Error('Workflow executor not provided');
|
|
77
|
+
}
|
|
78
|
+
result = await executeWorkflowFn(step, context);
|
|
79
|
+
break;
|
|
80
|
+
default:
|
|
81
|
+
throw new Error(`Unknown step type: ${(step as Step).type}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Apply transformation if specified and step succeeded
|
|
85
|
+
if (step.transform && result.status === 'success') {
|
|
86
|
+
const transformContext = {
|
|
87
|
+
// Provide raw output properties (like stdout, data) directly in context
|
|
88
|
+
// Fix: Spread output FIRST, then context to prevent shadowing
|
|
89
|
+
...(typeof result.output === 'object' && result.output !== null ? result.output : {}),
|
|
90
|
+
output: result.output,
|
|
91
|
+
...context,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
// If it's wrapped in ${{ }}, extract it, otherwise treat as raw expression
|
|
96
|
+
let expr = step.transform.trim();
|
|
97
|
+
if (expr.startsWith('${{') && expr.endsWith('}}')) {
|
|
98
|
+
expr = expr.slice(3, -2).trim();
|
|
99
|
+
}
|
|
100
|
+
result.output = ExpressionEvaluator.evaluateExpression(expr, transformContext);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Transform failed for step ${step.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return result;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return {
|
|
111
|
+
output: null,
|
|
112
|
+
status: 'failed',
|
|
113
|
+
error: error instanceof Error ? error.message : String(error),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Execute a shell step
|
|
120
|
+
*/
|
|
121
|
+
async function executeShellStep(
|
|
122
|
+
step: ShellStep,
|
|
123
|
+
context: ExpressionContext,
|
|
124
|
+
logger: Logger
|
|
125
|
+
): Promise<StepResult> {
|
|
126
|
+
const result = await executeShell(step, context, logger);
|
|
127
|
+
|
|
128
|
+
if (result.stdout) {
|
|
129
|
+
logger.log(result.stdout.trim());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (result.exitCode !== 0) {
|
|
133
|
+
return {
|
|
134
|
+
output: {
|
|
135
|
+
stdout: result.stdout,
|
|
136
|
+
stderr: result.stderr,
|
|
137
|
+
exitCode: result.exitCode,
|
|
138
|
+
},
|
|
139
|
+
status: 'failed',
|
|
140
|
+
error: `Shell command exited with code ${result.exitCode}: ${result.stderr}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
output: {
|
|
146
|
+
stdout: result.stdout,
|
|
147
|
+
stderr: result.stderr,
|
|
148
|
+
exitCode: result.exitCode,
|
|
149
|
+
},
|
|
150
|
+
status: 'success',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Execute a file step (read, write, append)
|
|
156
|
+
*/
|
|
157
|
+
async function executeFileStep(
|
|
158
|
+
step: FileStep,
|
|
159
|
+
context: ExpressionContext,
|
|
160
|
+
_logger: Logger
|
|
161
|
+
): Promise<StepResult> {
|
|
162
|
+
const path = ExpressionEvaluator.evaluate(step.path, context) as string;
|
|
163
|
+
|
|
164
|
+
switch (step.op) {
|
|
165
|
+
case 'read': {
|
|
166
|
+
const file = Bun.file(path);
|
|
167
|
+
if (!(await file.exists())) {
|
|
168
|
+
throw new Error(`File not found: ${path}`);
|
|
169
|
+
}
|
|
170
|
+
const content = await file.text();
|
|
171
|
+
return {
|
|
172
|
+
output: content,
|
|
173
|
+
status: 'success',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case 'write': {
|
|
178
|
+
if (!step.content) {
|
|
179
|
+
throw new Error('Content is required for write operation');
|
|
180
|
+
}
|
|
181
|
+
const content = ExpressionEvaluator.evaluate(step.content, context) as string;
|
|
182
|
+
const bytes = await Bun.write(path, content);
|
|
183
|
+
return {
|
|
184
|
+
output: { path, bytes },
|
|
185
|
+
status: 'success',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
case 'append': {
|
|
190
|
+
if (!step.content) {
|
|
191
|
+
throw new Error('Content is required for append operation');
|
|
192
|
+
}
|
|
193
|
+
const content = ExpressionEvaluator.evaluate(step.content, context) as string;
|
|
194
|
+
|
|
195
|
+
// Use Node.js fs for efficient append operation
|
|
196
|
+
const fs = await import('node:fs/promises');
|
|
197
|
+
await fs.appendFile(path, content, 'utf-8');
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
output: { path, bytes: content.length },
|
|
201
|
+
status: 'success',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
default:
|
|
206
|
+
throw new Error(`Unknown file operation: ${step.op}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Execute an HTTP request step
|
|
212
|
+
*/
|
|
213
|
+
async function executeRequestStep(
|
|
214
|
+
step: RequestStep,
|
|
215
|
+
context: ExpressionContext,
|
|
216
|
+
_logger: Logger
|
|
217
|
+
): Promise<StepResult> {
|
|
218
|
+
const url = ExpressionEvaluator.evaluate(step.url, context) as string;
|
|
219
|
+
|
|
220
|
+
// Evaluate headers
|
|
221
|
+
const headers: Record<string, string> = {};
|
|
222
|
+
if (step.headers) {
|
|
223
|
+
for (const [key, value] of Object.entries(step.headers)) {
|
|
224
|
+
headers[key] = ExpressionEvaluator.evaluate(value, context) as string;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Evaluate body
|
|
229
|
+
let body: string | undefined;
|
|
230
|
+
if (step.body) {
|
|
231
|
+
const evaluatedBody = ExpressionEvaluator.evaluateObject(step.body, context);
|
|
232
|
+
|
|
233
|
+
const contentType = Object.entries(headers).find(
|
|
234
|
+
([k]) => k.toLowerCase() === 'content-type'
|
|
235
|
+
)?.[1];
|
|
236
|
+
|
|
237
|
+
if (contentType?.includes('application/x-www-form-urlencoded')) {
|
|
238
|
+
if (typeof evaluatedBody === 'object' && evaluatedBody !== null) {
|
|
239
|
+
const params = new URLSearchParams();
|
|
240
|
+
for (const [key, value] of Object.entries(evaluatedBody)) {
|
|
241
|
+
params.append(key, String(value));
|
|
242
|
+
}
|
|
243
|
+
body = params.toString();
|
|
244
|
+
} else {
|
|
245
|
+
body = String(evaluatedBody);
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
// Default to JSON if not form-encoded and not already a string
|
|
249
|
+
body = typeof evaluatedBody === 'string' ? evaluatedBody : JSON.stringify(evaluatedBody);
|
|
250
|
+
|
|
251
|
+
// Auto-set Content-Type to application/json if not already set and body is an object
|
|
252
|
+
if (!contentType && typeof evaluatedBody === 'object' && evaluatedBody !== null) {
|
|
253
|
+
headers['Content-Type'] = 'application/json';
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const response = await fetch(url, {
|
|
259
|
+
method: step.method,
|
|
260
|
+
headers,
|
|
261
|
+
body,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const responseText = await response.text();
|
|
265
|
+
let responseData: unknown;
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
responseData = JSON.parse(responseText);
|
|
269
|
+
} catch {
|
|
270
|
+
responseData = responseText;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
output: {
|
|
275
|
+
status: response.status,
|
|
276
|
+
statusText: response.statusText,
|
|
277
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
278
|
+
data: responseData,
|
|
279
|
+
},
|
|
280
|
+
status: response.ok ? 'success' : 'failed',
|
|
281
|
+
error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Execute a human input step
|
|
287
|
+
*/
|
|
288
|
+
async function executeHumanStep(
|
|
289
|
+
step: HumanStep,
|
|
290
|
+
context: ExpressionContext,
|
|
291
|
+
logger: Logger
|
|
292
|
+
): Promise<StepResult> {
|
|
293
|
+
const message = ExpressionEvaluator.evaluate(step.message, context) as string;
|
|
294
|
+
|
|
295
|
+
// If not a TTY (e.g. MCP server), suspend execution
|
|
296
|
+
if (!process.stdin.isTTY) {
|
|
297
|
+
return {
|
|
298
|
+
output: null,
|
|
299
|
+
status: 'suspended',
|
|
300
|
+
error: message,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const rl = readline.createInterface({
|
|
305
|
+
input: process.stdin,
|
|
306
|
+
output: process.stdout,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
if (step.inputType === 'confirm') {
|
|
311
|
+
logger.log(`\n❓ ${message}`);
|
|
312
|
+
logger.log('Press Enter to continue, or Ctrl+C to cancel...');
|
|
313
|
+
await rl.question('');
|
|
314
|
+
return {
|
|
315
|
+
output: true,
|
|
316
|
+
status: 'success',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Text input
|
|
321
|
+
logger.log(`\n❓ ${message}`);
|
|
322
|
+
logger.log('Enter your response:');
|
|
323
|
+
const input = await rl.question('');
|
|
324
|
+
return {
|
|
325
|
+
output: input.trim(),
|
|
326
|
+
status: 'success',
|
|
327
|
+
};
|
|
328
|
+
} finally {
|
|
329
|
+
rl.close();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Execute a sleep step
|
|
335
|
+
*/
|
|
336
|
+
async function executeSleepStep(
|
|
337
|
+
step: SleepStep,
|
|
338
|
+
context: ExpressionContext,
|
|
339
|
+
_logger: Logger
|
|
340
|
+
): Promise<StepResult> {
|
|
341
|
+
const evaluated = ExpressionEvaluator.evaluate(step.duration.toString(), context);
|
|
342
|
+
const duration = Number(evaluated);
|
|
343
|
+
|
|
344
|
+
if (Number.isNaN(duration)) {
|
|
345
|
+
throw new Error(`Invalid sleep duration: ${evaluated}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await new Promise((resolve) => setTimeout(resolve, duration));
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
output: { slept: duration },
|
|
352
|
+
status: 'success',
|
|
353
|
+
};
|
|
354
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { TimeoutError, withTimeout } from './timeout';
|
|
3
|
+
|
|
4
|
+
describe('timeout', () => {
|
|
5
|
+
it('should resolve if the promise completes before the timeout', async () => {
|
|
6
|
+
const promise = Promise.resolve('ok');
|
|
7
|
+
const result = await withTimeout(promise, 100);
|
|
8
|
+
expect(result).toBe('ok');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should reject if the promise takes longer than the timeout', async () => {
|
|
12
|
+
const promise = new Promise((resolve) => setTimeout(() => resolve('ok'), 200));
|
|
13
|
+
await expect(withTimeout(promise, 50)).rejects.toThrow(TimeoutError);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should include the operation name in the error message', async () => {
|
|
17
|
+
const promise = new Promise((resolve) => setTimeout(() => resolve('ok'), 100));
|
|
18
|
+
await expect(withTimeout(promise, 10, 'MyStep')).rejects.toThrow(/MyStep timed out/);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute a promise with a timeout
|
|
3
|
+
* Throws a TimeoutError if the operation exceeds the timeout
|
|
4
|
+
*/
|
|
5
|
+
export class TimeoutError extends Error {
|
|
6
|
+
constructor(message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'TimeoutError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function withTimeout<T>(
|
|
13
|
+
promise: Promise<T>,
|
|
14
|
+
timeoutMs: number,
|
|
15
|
+
operation = 'Operation'
|
|
16
|
+
): Promise<T> {
|
|
17
|
+
let timeoutId: Timer;
|
|
18
|
+
|
|
19
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
20
|
+
timeoutId = setTimeout(() => {
|
|
21
|
+
reject(new TimeoutError(`${operation} timed out after ${timeoutMs}ms`));
|
|
22
|
+
}, timeoutMs);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
27
|
+
} finally {
|
|
28
|
+
clearTimeout(timeoutId);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import { OpenAIAdapter, CopilotAdapter, AnthropicAdapter } from './llm-adapter';
|
|
3
|
+
import { MCPClient } from './mcp-client';
|
|
4
|
+
import { executeLlmStep } from './llm-executor';
|
|
5
|
+
import type { LlmStep, Step } from '../parser/schema';
|
|
6
|
+
import type { ExpressionContext } from '../expression/evaluator';
|
|
7
|
+
import type { StepResult } from './step-executor';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
interface MockToolCall {
|
|
12
|
+
function: {
|
|
13
|
+
name: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('llm-executor with tools and MCP', () => {
|
|
18
|
+
const agentsDir = join(process.cwd(), '.keystone', 'workflows', 'agents');
|
|
19
|
+
const agentPath = join(agentsDir, 'tool-test-agent.md');
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
24
|
+
} catch (e) {}
|
|
25
|
+
const agentContent = `---
|
|
26
|
+
name: tool-test-agent
|
|
27
|
+
tools:
|
|
28
|
+
- name: agent-tool
|
|
29
|
+
execution:
|
|
30
|
+
id: agent-tool-exec
|
|
31
|
+
type: shell
|
|
32
|
+
run: echo "agent tool"
|
|
33
|
+
---
|
|
34
|
+
Test system prompt`;
|
|
35
|
+
writeFileSync(agentPath, agentContent);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
try {
|
|
40
|
+
unlinkSync(agentPath);
|
|
41
|
+
} catch (e) {}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should merge tools from agent, step and MCP', async () => {
|
|
45
|
+
const originalOpenAIChat = OpenAIAdapter.prototype.chat;
|
|
46
|
+
const originalCopilotChat = CopilotAdapter.prototype.chat;
|
|
47
|
+
const originalAnthropicChat = AnthropicAdapter.prototype.chat;
|
|
48
|
+
let capturedTools: MockToolCall[] = [];
|
|
49
|
+
|
|
50
|
+
const mockChat = mock(async (_messages: unknown, options: unknown) => {
|
|
51
|
+
capturedTools = (options as { tools?: MockToolCall[] })?.tools || [];
|
|
52
|
+
return {
|
|
53
|
+
message: { role: 'assistant', content: 'Final response' },
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
OpenAIAdapter.prototype.chat = mockChat as any;
|
|
58
|
+
CopilotAdapter.prototype.chat = mockChat as any;
|
|
59
|
+
AnthropicAdapter.prototype.chat = mockChat as any;
|
|
60
|
+
|
|
61
|
+
// Use mock.module for MCPClient
|
|
62
|
+
const originalInitialize = MCPClient.prototype.initialize;
|
|
63
|
+
const originalListTools = MCPClient.prototype.listTools;
|
|
64
|
+
const originalStop = MCPClient.prototype.stop;
|
|
65
|
+
|
|
66
|
+
const mockInitialize = mock(async () => ({}) as any);
|
|
67
|
+
const mockListTools = mock(async () => [
|
|
68
|
+
{
|
|
69
|
+
name: 'mcp-tool',
|
|
70
|
+
description: 'MCP tool',
|
|
71
|
+
inputSchema: { type: 'object', properties: {} },
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
const mockStop = mock(() => {});
|
|
75
|
+
|
|
76
|
+
MCPClient.prototype.initialize = mockInitialize;
|
|
77
|
+
MCPClient.prototype.listTools = mockListTools;
|
|
78
|
+
MCPClient.prototype.stop = mockStop;
|
|
79
|
+
|
|
80
|
+
const step: LlmStep = {
|
|
81
|
+
id: 'l1',
|
|
82
|
+
type: 'llm',
|
|
83
|
+
agent: 'tool-test-agent',
|
|
84
|
+
prompt: 'test',
|
|
85
|
+
needs: [],
|
|
86
|
+
tools: [
|
|
87
|
+
{
|
|
88
|
+
name: 'step-tool',
|
|
89
|
+
execution: { id: 'step-tool-exec', type: 'shell', run: 'echo step' },
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
mcpServers: [{ name: 'test-mcp', command: 'node', args: ['-e', ''] }],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
96
|
+
const executeStepFn = async () => ({ status: 'success' as const, output: {} });
|
|
97
|
+
|
|
98
|
+
await executeLlmStep(
|
|
99
|
+
step,
|
|
100
|
+
context,
|
|
101
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const toolNames = capturedTools.map((t) => t.function.name);
|
|
105
|
+
expect(toolNames).toContain('agent-tool');
|
|
106
|
+
expect(toolNames).toContain('step-tool');
|
|
107
|
+
expect(toolNames).toContain('mcp-tool');
|
|
108
|
+
|
|
109
|
+
OpenAIAdapter.prototype.chat = originalOpenAIChat;
|
|
110
|
+
CopilotAdapter.prototype.chat = originalCopilotChat;
|
|
111
|
+
AnthropicAdapter.prototype.chat = originalAnthropicChat;
|
|
112
|
+
MCPClient.prototype.initialize = originalInitialize;
|
|
113
|
+
MCPClient.prototype.listTools = originalListTools;
|
|
114
|
+
MCPClient.prototype.stop = originalStop;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should execute MCP tool when called', async () => {
|
|
118
|
+
const originalOpenAIChat = OpenAIAdapter.prototype.chat;
|
|
119
|
+
const originalCopilotChat = CopilotAdapter.prototype.chat;
|
|
120
|
+
const originalAnthropicChat = AnthropicAdapter.prototype.chat;
|
|
121
|
+
let chatCount = 0;
|
|
122
|
+
|
|
123
|
+
const mockChat = mock(async () => {
|
|
124
|
+
chatCount++;
|
|
125
|
+
if (chatCount === 1) {
|
|
126
|
+
return {
|
|
127
|
+
message: {
|
|
128
|
+
role: 'assistant',
|
|
129
|
+
tool_calls: [
|
|
130
|
+
{
|
|
131
|
+
id: 'call-1',
|
|
132
|
+
type: 'function',
|
|
133
|
+
function: { name: 'mcp-tool', arguments: '{}' },
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
message: { role: 'assistant', content: 'Done' },
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
OpenAIAdapter.prototype.chat = mockChat as any;
|
|
145
|
+
CopilotAdapter.prototype.chat = mockChat as any;
|
|
146
|
+
AnthropicAdapter.prototype.chat = mockChat as any;
|
|
147
|
+
|
|
148
|
+
const originalInitialize = MCPClient.prototype.initialize;
|
|
149
|
+
const originalListTools = MCPClient.prototype.listTools;
|
|
150
|
+
const originalCallTool = MCPClient.prototype.callTool;
|
|
151
|
+
const originalStop = MCPClient.prototype.stop;
|
|
152
|
+
|
|
153
|
+
const mockInitialize = mock(async () => ({}) as any);
|
|
154
|
+
const mockListTools = mock(async () => [
|
|
155
|
+
{
|
|
156
|
+
name: 'mcp-tool',
|
|
157
|
+
description: 'MCP tool',
|
|
158
|
+
inputSchema: { type: 'object', properties: {} },
|
|
159
|
+
},
|
|
160
|
+
]);
|
|
161
|
+
const mockCallTool = mock(async () => ({ result: 'mcp success' }));
|
|
162
|
+
const mockStop = mock(() => {});
|
|
163
|
+
|
|
164
|
+
MCPClient.prototype.initialize = mockInitialize;
|
|
165
|
+
MCPClient.prototype.listTools = mockListTools;
|
|
166
|
+
MCPClient.prototype.callTool = mockCallTool;
|
|
167
|
+
MCPClient.prototype.stop = mockStop;
|
|
168
|
+
|
|
169
|
+
const step: LlmStep = {
|
|
170
|
+
id: 'l1',
|
|
171
|
+
type: 'llm',
|
|
172
|
+
agent: 'tool-test-agent',
|
|
173
|
+
prompt: 'test',
|
|
174
|
+
needs: [],
|
|
175
|
+
mcpServers: [{ name: 'test-mcp', command: 'node', args: ['-e', ''] }],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
179
|
+
const executeStepFn = async () => ({ status: 'success' as const, output: {} });
|
|
180
|
+
|
|
181
|
+
await executeLlmStep(
|
|
182
|
+
step,
|
|
183
|
+
context,
|
|
184
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(mockCallTool).toHaveBeenCalledWith('mcp-tool', {});
|
|
188
|
+
expect(chatCount).toBe(2);
|
|
189
|
+
|
|
190
|
+
OpenAIAdapter.prototype.chat = originalOpenAIChat;
|
|
191
|
+
CopilotAdapter.prototype.chat = originalCopilotChat;
|
|
192
|
+
AnthropicAdapter.prototype.chat = originalAnthropicChat;
|
|
193
|
+
MCPClient.prototype.initialize = originalInitialize;
|
|
194
|
+
MCPClient.prototype.listTools = originalListTools;
|
|
195
|
+
MCPClient.prototype.callTool = originalCallTool;
|
|
196
|
+
MCPClient.prototype.stop = originalStop;
|
|
197
|
+
});
|
|
198
|
+
});
|