@blocks-network/mcp-server 0.1.55 → 0.1.62
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 +16 -14
- package/dist/index.js +35 -305
- package/dist/registry-list.d.ts +33 -0
- package/dist/registry-list.js +33 -0
- package/dist/tools.d.ts +152 -0
- package/dist/tools.js +275 -0
- package/package.json +18 -6
- package/src/index.ts +55 -341
- package/src/registry-list.ts +58 -0
- package/src/tools.ts +496 -0
- package/tests/cancel-task.test.ts +24 -0
- package/tests/connect-task.test.ts +180 -0
- package/tests/get-agent-card.test.ts +53 -0
- package/tests/get-task.test.ts +76 -0
- package/tests/helpers.ts +188 -0
- package/tests/list-agents.test.ts +74 -0
- package/tests/list-tasks.test.ts +58 -0
- package/tests/registry-list.test.ts +120 -0
- package/tests/send-task.test.ts +221 -0
- package/vitest.config.ts +7 -0
- package/LICENSE +0 -26
package/src/tools.ts
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool handlers for the Blocks Network MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Each handler is a pure function that takes its dependencies (clients,
|
|
5
|
+
* registry helpers, fs/path utilities) so it can be unit-tested without
|
|
6
|
+
* touching the network or the filesystem.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { statSync } from 'node:fs';
|
|
10
|
+
import {
|
|
11
|
+
textPart,
|
|
12
|
+
filePartFromPath,
|
|
13
|
+
type TaskInfo,
|
|
14
|
+
type SendMessageRequestPart,
|
|
15
|
+
} from '@blocks-network/sdk';
|
|
16
|
+
|
|
17
|
+
import type { ListAgentsResult } from './registry-list.js';
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Types — minimal local shapes mirroring @blocks-network/sdk surfaces.
|
|
21
|
+
// Using narrow interfaces (instead of importing the SDK classes) keeps
|
|
22
|
+
// the tests free of PubNub/runtime dependencies.
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
export interface ArtifactRef {
|
|
26
|
+
fileName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DownloadedArtifact {
|
|
30
|
+
fileName?: string;
|
|
31
|
+
mimeType: string;
|
|
32
|
+
data: Uint8Array;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StreamDescriptor {
|
|
36
|
+
localDirection: string;
|
|
37
|
+
format: string;
|
|
38
|
+
declaredStream?: string;
|
|
39
|
+
streamId: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface StreamClient {
|
|
43
|
+
events(): AsyncIterable<unknown>;
|
|
44
|
+
bytes(): AsyncIterable<Uint8Array>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface StreamRef {
|
|
48
|
+
descriptor: StreamDescriptor;
|
|
49
|
+
open(): StreamClient;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ProgressEvent {
|
|
53
|
+
message?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TerminalEvent {
|
|
57
|
+
state: string;
|
|
58
|
+
error?: string;
|
|
59
|
+
reason?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TaskSessionLike {
|
|
63
|
+
taskId: string;
|
|
64
|
+
onProgress(cb: (event: ProgressEvent) => void): unknown;
|
|
65
|
+
onStream(cb: (ref: StreamRef) => void): unknown;
|
|
66
|
+
listArtifacts(): ArtifactRef[];
|
|
67
|
+
listStreams(): StreamRef[];
|
|
68
|
+
downloadArtifact(ref: ArtifactRef): Promise<DownloadedArtifact>;
|
|
69
|
+
waitForTerminal(timeoutMs?: number): Promise<TerminalEvent>;
|
|
70
|
+
close(): void;
|
|
71
|
+
asyncClose(): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TaskClientLike {
|
|
75
|
+
sendMessage(params: {
|
|
76
|
+
agentName: string;
|
|
77
|
+
requestParts: SendMessageRequestPart[];
|
|
78
|
+
taskKind?: 'request' | 'pipe';
|
|
79
|
+
duration?: number;
|
|
80
|
+
}): Promise<TaskSessionLike>;
|
|
81
|
+
getTask(taskId: string): Promise<TaskInfo>;
|
|
82
|
+
listTasks(params: {
|
|
83
|
+
agentName?: string;
|
|
84
|
+
state?: string;
|
|
85
|
+
limit?: number;
|
|
86
|
+
}): Promise<{ tasks: TaskInfo[]; totalCount?: number }>;
|
|
87
|
+
cancelTask(taskId: string): Promise<unknown>;
|
|
88
|
+
connect(params: { taskId: string }): Promise<TaskSessionLike>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface AgentEntryLike {
|
|
92
|
+
agentName: string;
|
|
93
|
+
card?: {
|
|
94
|
+
io?: {
|
|
95
|
+
inputs?: Array<{ id: string; contentType: string }>;
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
billingMode?: 'free' | 'paid';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ToolResult {
|
|
102
|
+
[k: string]: unknown;
|
|
103
|
+
content: Array<{ type: 'text'; text: string }>;
|
|
104
|
+
isError?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ToolDeps {
|
|
108
|
+
getBaseUrl(): Promise<string>;
|
|
109
|
+
getApiKey(): string | undefined;
|
|
110
|
+
getTaskClient(billingMode?: 'free' | 'paid'): Promise<TaskClientLike>;
|
|
111
|
+
getAgentByName(
|
|
112
|
+
agentName: string,
|
|
113
|
+
options: { baseUrl: string; apiKey?: string },
|
|
114
|
+
): Promise<AgentEntryLike | null>;
|
|
115
|
+
listAgents(options: {
|
|
116
|
+
baseUrl: string;
|
|
117
|
+
apiKey?: string;
|
|
118
|
+
skill?: string;
|
|
119
|
+
listing?: 'public' | 'private';
|
|
120
|
+
limit?: number;
|
|
121
|
+
}): Promise<ListAgentsResult>;
|
|
122
|
+
validateFilePath(filePath: string): string;
|
|
123
|
+
fileSize(path: string): number;
|
|
124
|
+
maxUploadBytes: number;
|
|
125
|
+
filePartFromPath: typeof filePartFromPath;
|
|
126
|
+
textPart: typeof textPart;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// Helpers
|
|
131
|
+
// ============================================================================
|
|
132
|
+
|
|
133
|
+
const TERMINAL_STATES = new Set(['completed', 'failed', 'canceled']);
|
|
134
|
+
|
|
135
|
+
function resolveBillingMode(task: TaskInfo): 'free' | 'paid' {
|
|
136
|
+
const mode = task.billingMode as string | undefined;
|
|
137
|
+
return mode === 'paid' ? 'paid' : 'free';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isTextLikeContentType(ct: string): boolean {
|
|
141
|
+
return ct.startsWith('text/') || ct === 'application/json' || ct.endsWith('+json');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isTextMimeType(mimeType: string): boolean {
|
|
145
|
+
return mimeType.startsWith('text/') || mimeType === 'application/json';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function appendArtifacts(
|
|
149
|
+
session: TaskSessionLike,
|
|
150
|
+
refs: ArtifactRef[],
|
|
151
|
+
out: string[],
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
for (const ref of refs) {
|
|
154
|
+
try {
|
|
155
|
+
const downloaded = await session.downloadArtifact(ref);
|
|
156
|
+
if (isTextMimeType(downloaded.mimeType)) {
|
|
157
|
+
const text = new TextDecoder().decode(downloaded.data);
|
|
158
|
+
out.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}]\n${text}`);
|
|
159
|
+
} else {
|
|
160
|
+
out.push(
|
|
161
|
+
`[artifact: ${downloaded.fileName ?? 'unnamed'}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
out.push(`[artifact: ${ref.fileName ?? 'unnamed'}] (download failed)`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ============================================================================
|
|
171
|
+
// Tool params
|
|
172
|
+
// ============================================================================
|
|
173
|
+
|
|
174
|
+
export interface SendTaskParams {
|
|
175
|
+
agentName: string;
|
|
176
|
+
message: string;
|
|
177
|
+
filePath?: string;
|
|
178
|
+
inputs?: Record<string, string>;
|
|
179
|
+
taskKind?: 'request' | 'pipe';
|
|
180
|
+
duration?: number;
|
|
181
|
+
timeoutMs?: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface GetTaskParams {
|
|
185
|
+
taskId: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface ListTasksParams {
|
|
189
|
+
agentName?: string;
|
|
190
|
+
state?: string;
|
|
191
|
+
limit?: number;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface CancelTaskParams {
|
|
195
|
+
taskId: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ListAgentsParams {
|
|
199
|
+
skill?: string;
|
|
200
|
+
listing?: 'public' | 'private';
|
|
201
|
+
limit?: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface GetAgentCardParams {
|
|
205
|
+
agentName: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface ConnectTaskParams {
|
|
209
|
+
taskId: string;
|
|
210
|
+
timeoutMs?: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ============================================================================
|
|
214
|
+
// send_task
|
|
215
|
+
// ============================================================================
|
|
216
|
+
|
|
217
|
+
export async function sendTask(
|
|
218
|
+
params: SendTaskParams,
|
|
219
|
+
deps: ToolDeps,
|
|
220
|
+
): Promise<ToolResult> {
|
|
221
|
+
const baseUrl = await deps.getBaseUrl();
|
|
222
|
+
const apiKey = deps.getApiKey();
|
|
223
|
+
const entry = await deps.getAgentByName(params.agentName, { baseUrl, apiKey });
|
|
224
|
+
const billingMode: 'free' | 'paid' = entry?.billingMode ?? 'free';
|
|
225
|
+
const client = await deps.getTaskClient(billingMode);
|
|
226
|
+
|
|
227
|
+
const declaredInputs = entry?.card?.io?.inputs ?? [];
|
|
228
|
+
const textInput = declaredInputs.find((i) => isTextLikeContentType(i.contentType));
|
|
229
|
+
const fileInput = declaredInputs.find((i) => !isTextLikeContentType(i.contentType));
|
|
230
|
+
|
|
231
|
+
const requestParts: SendMessageRequestPart[] = [];
|
|
232
|
+
const textPartId = textInput?.id ?? 'text';
|
|
233
|
+
if ((textInput || declaredInputs.length === 0) && !params.inputs?.[textPartId]) {
|
|
234
|
+
requestParts.push(deps.textPart(params.message, textPartId));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (params.filePath) {
|
|
238
|
+
const safePath = deps.validateFilePath(params.filePath);
|
|
239
|
+
const size = deps.fileSize(safePath);
|
|
240
|
+
if (size > deps.maxUploadBytes) {
|
|
241
|
+
return {
|
|
242
|
+
content: [
|
|
243
|
+
{
|
|
244
|
+
type: 'text',
|
|
245
|
+
text: `File too large (${size} bytes). Maximum upload size is ${deps.maxUploadBytes} bytes (25 MB).`,
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
isError: true,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
requestParts.push(
|
|
252
|
+
await deps.filePartFromPath(safePath, {
|
|
253
|
+
partId: fileInput?.id ?? 'file',
|
|
254
|
+
contentType: fileInput?.contentType,
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (params.inputs) {
|
|
260
|
+
for (const [partId, value] of Object.entries(params.inputs)) {
|
|
261
|
+
requestParts.push(deps.textPart(value, partId));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const session = await client.sendMessage({
|
|
266
|
+
agentName: params.agentName,
|
|
267
|
+
requestParts,
|
|
268
|
+
taskKind: params.taskKind,
|
|
269
|
+
duration: params.duration,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const timeout = params.timeoutMs ?? 60000;
|
|
273
|
+
const progressLines: string[] = [];
|
|
274
|
+
session.onProgress((event) => {
|
|
275
|
+
if (event.message) progressLines.push(`[progress] ${event.message}`);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const terminal = await session.waitForTerminal(timeout);
|
|
280
|
+
const output = [`Task ${session.taskId} ${terminal.state}`, ...progressLines];
|
|
281
|
+
await appendArtifacts(session, session.listArtifacts(), output);
|
|
282
|
+
if (terminal.state === 'failed') {
|
|
283
|
+
output.push(`Error: ${terminal.error ?? terminal.reason ?? 'unknown'}`);
|
|
284
|
+
}
|
|
285
|
+
session.close();
|
|
286
|
+
return { content: [{ type: 'text', text: output.join('\n') }] };
|
|
287
|
+
} catch (err) {
|
|
288
|
+
session.close();
|
|
289
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
290
|
+
return {
|
|
291
|
+
content: [{ type: 'text', text: `Task ${session.taskId} error: ${msg}` }],
|
|
292
|
+
isError: true,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================================
|
|
298
|
+
// get_task
|
|
299
|
+
// ============================================================================
|
|
300
|
+
|
|
301
|
+
export async function getTask(
|
|
302
|
+
params: GetTaskParams,
|
|
303
|
+
deps: ToolDeps,
|
|
304
|
+
): Promise<ToolResult> {
|
|
305
|
+
const freeClient = await deps.getTaskClient('free');
|
|
306
|
+
const task = await freeClient.getTask(params.taskId);
|
|
307
|
+
const output = [JSON.stringify(task, null, 2)];
|
|
308
|
+
|
|
309
|
+
if (task.state && TERMINAL_STATES.has(task.state)) {
|
|
310
|
+
try {
|
|
311
|
+
const billingMode = resolveBillingMode(task);
|
|
312
|
+
const client = await deps.getTaskClient(billingMode);
|
|
313
|
+
const session = await client.connect({ taskId: params.taskId });
|
|
314
|
+
await appendArtifacts(session, session.listArtifacts(), output);
|
|
315
|
+
session.close();
|
|
316
|
+
} catch {
|
|
317
|
+
// connect failed — return task info without artifacts
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return { content: [{ type: 'text', text: output.join('\n') }] };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ============================================================================
|
|
325
|
+
// list_tasks
|
|
326
|
+
// ============================================================================
|
|
327
|
+
|
|
328
|
+
export async function listTasks(
|
|
329
|
+
params: ListTasksParams,
|
|
330
|
+
deps: ToolDeps,
|
|
331
|
+
): Promise<ToolResult> {
|
|
332
|
+
const client = await deps.getTaskClient();
|
|
333
|
+
const result = await client.listTasks({
|
|
334
|
+
agentName: params.agentName,
|
|
335
|
+
state: params.state,
|
|
336
|
+
limit: params.limit,
|
|
337
|
+
});
|
|
338
|
+
const lines = result.tasks.map(
|
|
339
|
+
(t: TaskInfo) =>
|
|
340
|
+
`${t.taskId} | ${t.agentName ?? '?'} | ${t.state ?? '?'} | ${t.createdTime ?? ''}`,
|
|
341
|
+
);
|
|
342
|
+
const header = `Tasks (${result.totalCount ?? result.tasks.length} total):`;
|
|
343
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ============================================================================
|
|
347
|
+
// cancel_task
|
|
348
|
+
// ============================================================================
|
|
349
|
+
|
|
350
|
+
export async function cancelTask(
|
|
351
|
+
params: CancelTaskParams,
|
|
352
|
+
deps: ToolDeps,
|
|
353
|
+
): Promise<ToolResult> {
|
|
354
|
+
const client = await deps.getTaskClient();
|
|
355
|
+
await client.cancelTask(params.taskId);
|
|
356
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} cancelled.` }] };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ============================================================================
|
|
360
|
+
// list_agents
|
|
361
|
+
// ============================================================================
|
|
362
|
+
|
|
363
|
+
export async function listAgents(
|
|
364
|
+
params: ListAgentsParams,
|
|
365
|
+
deps: ToolDeps,
|
|
366
|
+
): Promise<ToolResult> {
|
|
367
|
+
const baseUrl = await deps.getBaseUrl();
|
|
368
|
+
const apiKey = deps.getApiKey();
|
|
369
|
+
const result = await deps.listAgents({
|
|
370
|
+
baseUrl,
|
|
371
|
+
apiKey,
|
|
372
|
+
skill: params.skill,
|
|
373
|
+
listing: params.listing,
|
|
374
|
+
limit: params.limit,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const lines = result.agents.map((a) => {
|
|
378
|
+
const skills = a.skills?.map((s) => s.name).join(', ') ?? '';
|
|
379
|
+
return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${skills}`;
|
|
380
|
+
});
|
|
381
|
+
const header = `Agents (${result.totalCount ?? result.agents.length}):`;
|
|
382
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ============================================================================
|
|
386
|
+
// get_agent_card
|
|
387
|
+
// ============================================================================
|
|
388
|
+
|
|
389
|
+
export async function getAgentCard(
|
|
390
|
+
params: GetAgentCardParams,
|
|
391
|
+
deps: ToolDeps,
|
|
392
|
+
): Promise<ToolResult> {
|
|
393
|
+
const baseUrl = await deps.getBaseUrl();
|
|
394
|
+
const apiKey = deps.getApiKey();
|
|
395
|
+
const entry = await deps.getAgentByName(params.agentName, { baseUrl, apiKey });
|
|
396
|
+
if (!entry) {
|
|
397
|
+
return {
|
|
398
|
+
content: [{ type: 'text', text: `Agent "${params.agentName}" not found.` }],
|
|
399
|
+
isError: true,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
content: [{ type: 'text', text: JSON.stringify(entry.card ?? entry, null, 2) }],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ============================================================================
|
|
408
|
+
// connect_task
|
|
409
|
+
// ============================================================================
|
|
410
|
+
|
|
411
|
+
export async function connectTask(
|
|
412
|
+
params: ConnectTaskParams,
|
|
413
|
+
deps: ToolDeps,
|
|
414
|
+
): Promise<ToolResult> {
|
|
415
|
+
const freeClient = await deps.getTaskClient('free');
|
|
416
|
+
const task = await freeClient.getTask(params.taskId);
|
|
417
|
+
const billingMode = resolveBillingMode(task);
|
|
418
|
+
const client = await deps.getTaskClient(billingMode);
|
|
419
|
+
const session = await client.connect({ taskId: params.taskId });
|
|
420
|
+
|
|
421
|
+
const timeout = params.timeoutMs ?? 60000;
|
|
422
|
+
const progressLines: string[] = [];
|
|
423
|
+
const streamOutputs: Array<{ label: string; chunks: string[] }> = [];
|
|
424
|
+
const streamDrains: Promise<void>[] = [];
|
|
425
|
+
|
|
426
|
+
session.onProgress((event) => {
|
|
427
|
+
if (event.message) progressLines.push(`[progress] ${event.message}`);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const drainStream = (streamRef: StreamRef) => {
|
|
431
|
+
const dir = streamRef.descriptor.localDirection;
|
|
432
|
+
if (dir !== 'inbound' && dir !== 'bidirectional') return;
|
|
433
|
+
const format = streamRef.descriptor.format;
|
|
434
|
+
const label = streamRef.descriptor.declaredStream ?? streamRef.descriptor.streamId;
|
|
435
|
+
const entry = { label, chunks: [] as string[] };
|
|
436
|
+
streamOutputs.push(entry);
|
|
437
|
+
try {
|
|
438
|
+
const streamClient = streamRef.open();
|
|
439
|
+
const drain = (async () => {
|
|
440
|
+
if (format === 'events') {
|
|
441
|
+
for await (const event of streamClient.events()) {
|
|
442
|
+
entry.chunks.push(JSON.stringify(event) + '\n');
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
const decoder = new TextDecoder();
|
|
446
|
+
for await (const chunk of streamClient.bytes()) {
|
|
447
|
+
entry.chunks.push(decoder.decode(chunk, { stream: true }));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
})();
|
|
451
|
+
streamDrains.push(drain.catch(() => {}));
|
|
452
|
+
} catch {
|
|
453
|
+
// stream already ended or terminal
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
for (const ref of session.listStreams()) drainStream(ref);
|
|
458
|
+
session.onStream(drainStream);
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
const terminal = await session.waitForTerminal(timeout);
|
|
462
|
+
await Promise.allSettled(streamDrains);
|
|
463
|
+
const out: string[] = [`Task ${params.taskId} ${terminal.state}`, ...progressLines];
|
|
464
|
+
|
|
465
|
+
for (const s of streamOutputs) {
|
|
466
|
+
if (s.chunks.length > 0) {
|
|
467
|
+
out.push(`[stream: ${s.label}]\n${s.chunks.join('')}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
await appendArtifacts(session, session.listArtifacts(), out);
|
|
471
|
+
if (terminal.state === 'failed') {
|
|
472
|
+
out.push(`Error: ${terminal.error ?? terminal.reason ?? 'unknown'}`);
|
|
473
|
+
}
|
|
474
|
+
await session.asyncClose();
|
|
475
|
+
return { content: [{ type: 'text', text: out.join('\n') }] };
|
|
476
|
+
} catch (err) {
|
|
477
|
+
await session.asyncClose();
|
|
478
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
479
|
+
const partial: string[] = [`Task ${params.taskId} timed out (${msg})`];
|
|
480
|
+
for (const s of streamOutputs) {
|
|
481
|
+
if (s.chunks.length > 0) {
|
|
482
|
+
partial.push(`[stream: ${s.label}]\n${s.chunks.join('')}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
partial.push(...progressLines);
|
|
486
|
+
return { content: [{ type: 'text', text: partial.join('\n') }], isError: true };
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ============================================================================
|
|
491
|
+
// Default file helpers (re-exported so index.ts can build a default ToolDeps)
|
|
492
|
+
// ============================================================================
|
|
493
|
+
|
|
494
|
+
export function defaultFileSize(path: string): number {
|
|
495
|
+
return statSync(path).size;
|
|
496
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { cancelTask } from '../src/tools.js';
|
|
3
|
+
import { makeFakeDeps, makeFakeClient } from './helpers.js';
|
|
4
|
+
|
|
5
|
+
describe('cancel_task', () => {
|
|
6
|
+
it('calls client.cancelTask with the supplied id and confirms', async () => {
|
|
7
|
+
const client = makeFakeClient();
|
|
8
|
+
const { deps } = makeFakeDeps({ client });
|
|
9
|
+
|
|
10
|
+
const res = await cancelTask({ taskId: 'task_xyz' }, deps);
|
|
11
|
+
|
|
12
|
+
expect(client.cancelTaskMock).toHaveBeenCalledWith('task_xyz');
|
|
13
|
+
expect(res.content[0].text).toBe('Task task_xyz cancelled.');
|
|
14
|
+
expect(res.isError).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('propagates errors from cancelTask', async () => {
|
|
18
|
+
const client = makeFakeClient();
|
|
19
|
+
client.cancelTaskMock.mockRejectedValueOnce(new Error('not allowed'));
|
|
20
|
+
const { deps } = makeFakeDeps({ client });
|
|
21
|
+
|
|
22
|
+
await expect(cancelTask({ taskId: 'task_zzz' }, deps)).rejects.toThrow('not allowed');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { connectTask } from '../src/tools.js';
|
|
3
|
+
import {
|
|
4
|
+
asyncIterFrom,
|
|
5
|
+
makeFakeClient,
|
|
6
|
+
makeFakeDeps,
|
|
7
|
+
makeFakeSession,
|
|
8
|
+
} from './helpers.js';
|
|
9
|
+
import type { StreamRef } from '../src/tools.js';
|
|
10
|
+
|
|
11
|
+
function makeStreamRef(opts: {
|
|
12
|
+
streamId: string;
|
|
13
|
+
declaredStream?: string;
|
|
14
|
+
format: 'events' | 'bytes';
|
|
15
|
+
localDirection: 'inbound' | 'outbound' | 'bidirectional';
|
|
16
|
+
events?: unknown[];
|
|
17
|
+
bytes?: Uint8Array[];
|
|
18
|
+
}): StreamRef {
|
|
19
|
+
return {
|
|
20
|
+
descriptor: {
|
|
21
|
+
streamId: opts.streamId,
|
|
22
|
+
declaredStream: opts.declaredStream,
|
|
23
|
+
format: opts.format,
|
|
24
|
+
localDirection: opts.localDirection,
|
|
25
|
+
},
|
|
26
|
+
open: () => ({
|
|
27
|
+
events: () => asyncIterFrom(opts.events ?? []),
|
|
28
|
+
bytes: () => asyncIterFrom(opts.bytes ?? []),
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('connect_task', () => {
|
|
34
|
+
it('drains inbound event streams and includes them in the output', async () => {
|
|
35
|
+
const stream = makeStreamRef({
|
|
36
|
+
streamId: 's1',
|
|
37
|
+
declaredStream: 'thoughts',
|
|
38
|
+
format: 'events',
|
|
39
|
+
localDirection: 'inbound',
|
|
40
|
+
events: [{ kind: 'tick', n: 1 }, { kind: 'tick', n: 2 }],
|
|
41
|
+
});
|
|
42
|
+
const session = makeFakeSession({
|
|
43
|
+
taskId: 't1',
|
|
44
|
+
streams: [stream],
|
|
45
|
+
terminal: { state: 'completed' },
|
|
46
|
+
});
|
|
47
|
+
const client = makeFakeClient({
|
|
48
|
+
task: { taskId: 't1', state: 'running' },
|
|
49
|
+
session,
|
|
50
|
+
});
|
|
51
|
+
const { deps } = makeFakeDeps({ client });
|
|
52
|
+
|
|
53
|
+
const res = await connectTask({ taskId: 't1' }, deps);
|
|
54
|
+
|
|
55
|
+
expect(res.content[0].text).toContain('Task t1 completed');
|
|
56
|
+
expect(res.content[0].text).toContain('[stream: thoughts]');
|
|
57
|
+
expect(res.content[0].text).toContain('{"kind":"tick","n":1}');
|
|
58
|
+
expect(res.content[0].text).toContain('{"kind":"tick","n":2}');
|
|
59
|
+
expect(session.asyncCloseMock).toHaveBeenCalledOnce();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('drains inbound byte streams and decodes UTF-8', async () => {
|
|
63
|
+
const stream = makeStreamRef({
|
|
64
|
+
streamId: 's2',
|
|
65
|
+
format: 'bytes',
|
|
66
|
+
localDirection: 'inbound',
|
|
67
|
+
bytes: [
|
|
68
|
+
new TextEncoder().encode('hello '),
|
|
69
|
+
new TextEncoder().encode('world'),
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
const session = makeFakeSession({
|
|
73
|
+
streams: [stream],
|
|
74
|
+
terminal: { state: 'completed' },
|
|
75
|
+
});
|
|
76
|
+
const client = makeFakeClient({
|
|
77
|
+
task: { taskId: 't2', state: 'running' },
|
|
78
|
+
session,
|
|
79
|
+
});
|
|
80
|
+
const { deps } = makeFakeDeps({ client });
|
|
81
|
+
|
|
82
|
+
const res = await connectTask({ taskId: 't2' }, deps);
|
|
83
|
+
expect(res.content[0].text).toContain('[stream: s2]\nhello world');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('skips outbound-only streams', async () => {
|
|
87
|
+
const stream = makeStreamRef({
|
|
88
|
+
streamId: 's3',
|
|
89
|
+
format: 'events',
|
|
90
|
+
localDirection: 'outbound',
|
|
91
|
+
events: [{ ignored: true }],
|
|
92
|
+
});
|
|
93
|
+
const session = makeFakeSession({
|
|
94
|
+
streams: [stream],
|
|
95
|
+
terminal: { state: 'completed' },
|
|
96
|
+
});
|
|
97
|
+
const client = makeFakeClient({
|
|
98
|
+
task: { taskId: 't3', state: 'running' },
|
|
99
|
+
session,
|
|
100
|
+
});
|
|
101
|
+
const { deps } = makeFakeDeps({ client });
|
|
102
|
+
|
|
103
|
+
const res = await connectTask({ taskId: 't3' }, deps);
|
|
104
|
+
expect(res.content[0].text).not.toContain('[stream:');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('drains streams that arrive after connect via onStream callback', async () => {
|
|
108
|
+
const session = makeFakeSession({
|
|
109
|
+
streams: [],
|
|
110
|
+
lateStreams: [
|
|
111
|
+
makeStreamRef({
|
|
112
|
+
streamId: 'late',
|
|
113
|
+
format: 'events',
|
|
114
|
+
localDirection: 'bidirectional',
|
|
115
|
+
events: [{ msg: 'late-arrival' }],
|
|
116
|
+
}),
|
|
117
|
+
],
|
|
118
|
+
terminal: { state: 'completed' },
|
|
119
|
+
});
|
|
120
|
+
const client = makeFakeClient({
|
|
121
|
+
task: { taskId: 't4', state: 'running' },
|
|
122
|
+
session,
|
|
123
|
+
});
|
|
124
|
+
const { deps } = makeFakeDeps({ client });
|
|
125
|
+
|
|
126
|
+
const res = await connectTask({ taskId: 't4' }, deps);
|
|
127
|
+
expect(res.content[0].text).toContain('[stream: late]');
|
|
128
|
+
expect(res.content[0].text).toContain('"late-arrival"');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('returns isError=true and partial stream output when waitForTerminal rejects', async () => {
|
|
132
|
+
const stream = makeStreamRef({
|
|
133
|
+
streamId: 's5',
|
|
134
|
+
format: 'events',
|
|
135
|
+
localDirection: 'inbound',
|
|
136
|
+
events: [{ tick: 1 }],
|
|
137
|
+
});
|
|
138
|
+
const session = makeFakeSession({
|
|
139
|
+
streams: [stream],
|
|
140
|
+
terminalRejects: new Error('timeout'),
|
|
141
|
+
});
|
|
142
|
+
const client = makeFakeClient({
|
|
143
|
+
task: { taskId: 't5', state: 'running' },
|
|
144
|
+
session,
|
|
145
|
+
});
|
|
146
|
+
const { deps } = makeFakeDeps({ client });
|
|
147
|
+
|
|
148
|
+
const res = await connectTask({ taskId: 't5' }, deps);
|
|
149
|
+
|
|
150
|
+
expect(res.isError).toBe(true);
|
|
151
|
+
expect(res.content[0].text).toContain('Task t5 timed out (timeout)');
|
|
152
|
+
expect(session.asyncCloseMock).toHaveBeenCalledOnce();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('routes paid tasks through the paid TaskClient', async () => {
|
|
156
|
+
const client = makeFakeClient({
|
|
157
|
+
task: { taskId: 't6', state: 'running', billingMode: 'paid' },
|
|
158
|
+
});
|
|
159
|
+
const { deps, mocks } = makeFakeDeps({ client });
|
|
160
|
+
|
|
161
|
+
await connectTask({ taskId: 't6' }, deps);
|
|
162
|
+
|
|
163
|
+
expect(mocks.getTaskClient).toHaveBeenCalledWith('free');
|
|
164
|
+
expect(mocks.getTaskClient).toHaveBeenCalledWith('paid');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('appends "Error:" line for failed terminals', async () => {
|
|
168
|
+
const session = makeFakeSession({
|
|
169
|
+
terminal: { state: 'failed', reason: 'boom' },
|
|
170
|
+
});
|
|
171
|
+
const client = makeFakeClient({
|
|
172
|
+
task: { taskId: 't7', state: 'running' },
|
|
173
|
+
session,
|
|
174
|
+
});
|
|
175
|
+
const { deps } = makeFakeDeps({ client });
|
|
176
|
+
|
|
177
|
+
const res = await connectTask({ taskId: 't7' }, deps);
|
|
178
|
+
expect(res.content[0].text).toContain('Error: boom');
|
|
179
|
+
});
|
|
180
|
+
});
|