@blocks-network/mcp-server 0.1.62 → 0.1.64

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/src/tools.ts CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  } from '@blocks-network/sdk';
16
16
 
17
17
  import type { ListAgentsResult } from './registry-list.js';
18
+ import type { AgentStatusResponse } from './agent-status.js';
19
+ import type { ConsumerBalance, TopUpSession } from './billing.js';
18
20
 
19
21
  // ============================================================================
20
22
  // Types — minimal local shapes mirroring @blocks-network/sdk surfaces.
@@ -85,6 +87,9 @@ export interface TaskClientLike {
85
87
  limit?: number;
86
88
  }): Promise<{ tasks: TaskInfo[]; totalCount?: number }>;
87
89
  cancelTask(taskId: string): Promise<unknown>;
90
+ pauseTask(taskId: string): Promise<unknown>;
91
+ resumeTask(taskId: string): Promise<unknown>;
92
+ retryTask(taskId: string): Promise<unknown>;
88
93
  connect(params: { taskId: string }): Promise<TaskSessionLike>;
89
94
  }
90
95
 
@@ -115,15 +120,34 @@ export interface ToolDeps {
115
120
  listAgents(options: {
116
121
  baseUrl: string;
117
122
  apiKey?: string;
118
- skill?: string;
123
+ tag?: string;
119
124
  listing?: 'public' | 'private';
120
125
  limit?: number;
121
126
  }): Promise<ListAgentsResult>;
122
127
  validateFilePath(filePath: string): string;
128
+ resolveSavePath(filePath: string): string;
129
+ writeFile(filePath: string, data: Uint8Array): void;
123
130
  fileSize(path: string): number;
124
131
  maxUploadBytes: number;
125
132
  filePartFromPath: typeof filePartFromPath;
126
133
  textPart: typeof textPart;
134
+ getOrgId(): string | undefined;
135
+ fetchAgentStatus(options: {
136
+ baseUrl: string;
137
+ apiKey?: string;
138
+ agentNames: string[];
139
+ }): Promise<AgentStatusResponse>;
140
+ getConsumerBalance(options: {
141
+ baseUrl: string;
142
+ apiKey: string;
143
+ orgId: string;
144
+ }): Promise<ConsumerBalance>;
145
+ createConsumerTopUp(options: {
146
+ baseUrl: string;
147
+ apiKey: string;
148
+ orgId: string;
149
+ amountUsd: number;
150
+ }): Promise<TopUpSession>;
127
151
  }
128
152
 
129
153
  // ============================================================================
@@ -196,7 +220,7 @@ export interface CancelTaskParams {
196
220
  }
197
221
 
198
222
  export interface ListAgentsParams {
199
- skill?: string;
223
+ tag?: string;
200
224
  listing?: 'public' | 'private';
201
225
  limit?: number;
202
226
  }
@@ -210,6 +234,24 @@ export interface ConnectTaskParams {
210
234
  timeoutMs?: number;
211
235
  }
212
236
 
237
+ export interface DownloadArtifactParams {
238
+ taskId: string;
239
+ fileName: string;
240
+ savePath?: string;
241
+ }
242
+
243
+ export interface TaskIdParams {
244
+ taskId: string;
245
+ }
246
+
247
+ export interface GetAgentStatusParams {
248
+ agentNames: string[];
249
+ }
250
+
251
+ export interface RequestTopUpParams {
252
+ amountUsd: number;
253
+ }
254
+
213
255
  // ============================================================================
214
256
  // send_task
215
257
  // ============================================================================
@@ -369,14 +411,14 @@ export async function listAgents(
369
411
  const result = await deps.listAgents({
370
412
  baseUrl,
371
413
  apiKey,
372
- skill: params.skill,
414
+ tag: params.tag,
373
415
  listing: params.listing,
374
416
  limit: params.limit,
375
417
  });
376
418
 
377
419
  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}`;
420
+ const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
421
+ return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
380
422
  });
381
423
  const header = `Agents (${result.totalCount ?? result.agents.length}):`;
382
424
  return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
@@ -487,6 +529,207 @@ export async function connectTask(
487
529
  }
488
530
  }
489
531
 
532
+ // ============================================================================
533
+ // download_artifact
534
+ // ============================================================================
535
+
536
+ function bytesToBase64(bytes: Uint8Array): string {
537
+ let binary = '';
538
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
539
+ return Buffer.from(binary, 'binary').toString('base64');
540
+ }
541
+
542
+ export async function downloadArtifact(
543
+ params: DownloadArtifactParams,
544
+ deps: ToolDeps,
545
+ ): Promise<ToolResult> {
546
+ const freeClient = await deps.getTaskClient('free');
547
+ const task = await freeClient.getTask(params.taskId);
548
+ const billingMode = resolveBillingMode(task);
549
+ const client = await deps.getTaskClient(billingMode);
550
+
551
+ let session: TaskSessionLike;
552
+ try {
553
+ session = await client.connect({ taskId: params.taskId });
554
+ } catch (err) {
555
+ const msg = err instanceof Error ? err.message : String(err);
556
+ return {
557
+ content: [{ type: 'text', text: `Failed to connect to task ${params.taskId}: ${msg}` }],
558
+ isError: true,
559
+ };
560
+ }
561
+
562
+ try {
563
+ const refs = session.listArtifacts();
564
+ const ref = refs.find((r) => r.fileName === params.fileName);
565
+ if (!ref) {
566
+ const available = refs.map((r) => r.fileName ?? 'unnamed').join(', ') || '(none)';
567
+ return {
568
+ content: [
569
+ {
570
+ type: 'text',
571
+ text: `Artifact "${params.fileName}" not found on task ${params.taskId}. Available: ${available}`,
572
+ },
573
+ ],
574
+ isError: true,
575
+ };
576
+ }
577
+
578
+ const downloaded = await session.downloadArtifact(ref);
579
+
580
+ if (params.savePath) {
581
+ const safePath = deps.resolveSavePath(params.savePath);
582
+ deps.writeFile(safePath, downloaded.data);
583
+ return {
584
+ content: [
585
+ {
586
+ type: 'text',
587
+ text: `Saved ${downloaded.data.length} bytes to ${safePath} (${downloaded.mimeType})`,
588
+ },
589
+ ],
590
+ };
591
+ }
592
+
593
+ if (isTextMimeType(downloaded.mimeType)) {
594
+ const text = new TextDecoder().decode(downloaded.data);
595
+ return {
596
+ content: [
597
+ {
598
+ type: 'text',
599
+ text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)\n${text}`,
600
+ },
601
+ ],
602
+ };
603
+ }
604
+
605
+ return {
606
+ content: [
607
+ {
608
+ type: 'text',
609
+ text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes, base64)\n${bytesToBase64(downloaded.data)}`,
610
+ },
611
+ ],
612
+ };
613
+ } finally {
614
+ session.close();
615
+ }
616
+ }
617
+
618
+ // ============================================================================
619
+ // pause_task / resume_task / retry_task
620
+ // ============================================================================
621
+
622
+ export async function pauseTask(
623
+ params: TaskIdParams,
624
+ deps: ToolDeps,
625
+ ): Promise<ToolResult> {
626
+ const client = await deps.getTaskClient();
627
+ await client.pauseTask(params.taskId);
628
+ return { content: [{ type: 'text', text: `Task ${params.taskId} paused.` }] };
629
+ }
630
+
631
+ export async function resumeTask(
632
+ params: TaskIdParams,
633
+ deps: ToolDeps,
634
+ ): Promise<ToolResult> {
635
+ const client = await deps.getTaskClient();
636
+ await client.resumeTask(params.taskId);
637
+ return { content: [{ type: 'text', text: `Task ${params.taskId} resumed.` }] };
638
+ }
639
+
640
+ export async function retryTask(
641
+ params: TaskIdParams,
642
+ deps: ToolDeps,
643
+ ): Promise<ToolResult> {
644
+ const client = await deps.getTaskClient();
645
+ await client.retryTask(params.taskId);
646
+ return { content: [{ type: 'text', text: `Task ${params.taskId} retry requested.` }] };
647
+ }
648
+
649
+ // ============================================================================
650
+ // get_agent_status
651
+ // ============================================================================
652
+
653
+ export async function getAgentStatus(
654
+ params: GetAgentStatusParams,
655
+ deps: ToolDeps,
656
+ ): Promise<ToolResult> {
657
+ const baseUrl = await deps.getBaseUrl();
658
+ const apiKey = deps.getApiKey();
659
+ const result = await deps.fetchAgentStatus({
660
+ baseUrl,
661
+ apiKey,
662
+ agentNames: params.agentNames,
663
+ });
664
+ return {
665
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
666
+ };
667
+ }
668
+
669
+ // ============================================================================
670
+ // check_balance / request_topup
671
+ // ============================================================================
672
+
673
+ function requireBillingContext(deps: ToolDeps): { apiKey: string; orgId: string } | ToolResult {
674
+ const apiKey = deps.getApiKey();
675
+ if (!apiKey) {
676
+ return {
677
+ content: [{ type: 'text', text: 'BLOCKS_API_KEY is required for billing operations.' }],
678
+ isError: true,
679
+ };
680
+ }
681
+ const orgId = deps.getOrgId();
682
+ if (!orgId) {
683
+ return {
684
+ content: [
685
+ {
686
+ type: 'text',
687
+ text: 'BLOCKS_ORG_ID is required for billing operations. Set it from your dashboard URL or `blocks whoami --json`.',
688
+ },
689
+ ],
690
+ isError: true,
691
+ };
692
+ }
693
+ return { apiKey, orgId };
694
+ }
695
+
696
+ export async function checkBalance(
697
+ _params: Record<string, never>,
698
+ deps: ToolDeps,
699
+ ): Promise<ToolResult> {
700
+ const ctx = requireBillingContext(deps);
701
+ if ('content' in ctx) return ctx;
702
+ const baseUrl = await deps.getBaseUrl();
703
+ const balance = await deps.getConsumerBalance({
704
+ baseUrl,
705
+ apiKey: ctx.apiKey,
706
+ orgId: ctx.orgId,
707
+ });
708
+ return {
709
+ content: [{ type: 'text', text: JSON.stringify(balance, null, 2) }],
710
+ };
711
+ }
712
+
713
+ export async function requestTopup(
714
+ params: RequestTopUpParams,
715
+ deps: ToolDeps,
716
+ ): Promise<ToolResult> {
717
+ const ctx = requireBillingContext(deps);
718
+ if ('content' in ctx) return ctx;
719
+ const baseUrl = await deps.getBaseUrl();
720
+ const session = await deps.createConsumerTopUp({
721
+ baseUrl,
722
+ apiKey: ctx.apiKey,
723
+ orgId: ctx.orgId,
724
+ amountUsd: params.amountUsd,
725
+ });
726
+ const url = session.checkoutUrl;
727
+ const body = url
728
+ ? `Open this URL in a browser to complete the $${params.amountUsd.toFixed(2)} top-up:\n${url}`
729
+ : JSON.stringify(session, null, 2);
730
+ return { content: [{ type: 'text', text: body }] };
731
+ }
732
+
490
733
  // ============================================================================
491
734
  // Default file helpers (re-exported so index.ts can build a default ToolDeps)
492
735
  // ============================================================================
@@ -0,0 +1,203 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { checkBalance, requestTopup } from '../src/tools.js';
3
+ import { getConsumerBalance, createConsumerTopUp } from '../src/billing.js';
4
+ import {
5
+ PROTOCOL_VERSION_HEADER,
6
+ CURRENT_PROTOCOL_VERSION,
7
+ } from '../src/protocol-headers.js';
8
+ import { makeFakeDeps } from './helpers.js';
9
+
10
+ function mockResponse(body: unknown, ok = true, status = 200): Response {
11
+ return {
12
+ ok,
13
+ status,
14
+ json: async () => body,
15
+ text: async () => JSON.stringify(body),
16
+ } as Response;
17
+ }
18
+
19
+ describe('check_balance (handler)', () => {
20
+ it('returns an error when BLOCKS_API_KEY is missing', async () => {
21
+ const { deps } = makeFakeDeps();
22
+
23
+ const res = await checkBalance({}, deps);
24
+
25
+ expect(res.isError).toBe(true);
26
+ expect(res.content[0].text).toMatch(/BLOCKS_API_KEY/);
27
+ });
28
+
29
+ it('returns an error when BLOCKS_ORG_ID is missing', async () => {
30
+ const { deps } = makeFakeDeps({ apiKey: 'bk_test' });
31
+
32
+ const res = await checkBalance({}, deps);
33
+
34
+ expect(res.isError).toBe(true);
35
+ expect(res.content[0].text).toMatch(/BLOCKS_ORG_ID/);
36
+ });
37
+
38
+ it('forwards baseUrl, orgId, apiKey and emits balance JSON', async () => {
39
+ const balance = {
40
+ balance: '100.00',
41
+ reservedBalance: '5.00',
42
+ availableBalance: '95.00',
43
+ updatedAt: '2026-05-28T00:00:00Z',
44
+ };
45
+ const { deps, mocks } = makeFakeDeps({
46
+ apiKey: 'bk_test',
47
+ orgId: 'org_42',
48
+ balance,
49
+ });
50
+
51
+ const res = await checkBalance({}, deps);
52
+
53
+ expect(mocks.getConsumerBalance).toHaveBeenCalledWith({
54
+ baseUrl: 'http://api.test',
55
+ apiKey: 'bk_test',
56
+ orgId: 'org_42',
57
+ });
58
+ expect(JSON.parse(res.content[0].text)).toEqual(balance);
59
+ });
60
+ });
61
+
62
+ describe('request_topup (handler)', () => {
63
+ it('returns an error when BLOCKS_ORG_ID is missing', async () => {
64
+ const { deps } = makeFakeDeps({ apiKey: 'bk_test' });
65
+
66
+ const res = await requestTopup({ amountUsd: 25 }, deps);
67
+
68
+ expect(res.isError).toBe(true);
69
+ expect(res.content[0].text).toMatch(/BLOCKS_ORG_ID/);
70
+ });
71
+
72
+ it('returns the Stripe checkout URL with a human-readable label', async () => {
73
+ const { deps, mocks } = makeFakeDeps({
74
+ apiKey: 'bk_test',
75
+ orgId: 'org_42',
76
+ topUpSession: {
77
+ checkoutUrl: 'https://checkout.stripe.test/session/xyz',
78
+ sessionId: 'cs_test_abc',
79
+ },
80
+ });
81
+
82
+ const res = await requestTopup({ amountUsd: 25 }, deps);
83
+
84
+ expect(mocks.createConsumerTopUp).toHaveBeenCalledWith({
85
+ baseUrl: 'http://api.test',
86
+ apiKey: 'bk_test',
87
+ orgId: 'org_42',
88
+ amountUsd: 25,
89
+ });
90
+ expect(res.content[0].text).toContain('https://checkout.stripe.test/session/xyz');
91
+ expect(res.content[0].text).toContain('$25.00');
92
+ });
93
+ });
94
+
95
+ describe('getConsumerBalance (HTTP helper)', () => {
96
+ it('GETs /api/v1/billing/:orgId/consumer/balance with Bearer auth', async () => {
97
+ const fetchImpl = vi
98
+ .fn()
99
+ .mockResolvedValue(
100
+ mockResponse({
101
+ balance: '0',
102
+ reservedBalance: '0',
103
+ availableBalance: '0',
104
+ updatedAt: '2026-05-28T00:00:00Z',
105
+ }),
106
+ );
107
+
108
+ await getConsumerBalance({
109
+ baseUrl: 'http://api.test',
110
+ orgId: 'org_42',
111
+ apiKey: 'bk_test',
112
+ fetchImpl: fetchImpl as unknown as typeof fetch,
113
+ });
114
+
115
+ const [url, init] = fetchImpl.mock.calls[0];
116
+ const parsed = new URL(String(url));
117
+ expect(parsed.pathname).toBe('/api/v1/billing/org_42/consumer/balance');
118
+ expect(init?.method).toBe('GET');
119
+ const headers = init?.headers as Record<string, string>;
120
+ expect(headers.Authorization).toBe('Bearer bk_test');
121
+ expect(headers[PROTOCOL_VERSION_HEADER]).toBe(CURRENT_PROTOCOL_VERSION);
122
+ });
123
+
124
+ it('throws on non-OK responses', async () => {
125
+ const fetchImpl = vi.fn().mockResolvedValue(mockResponse({}, false, 403));
126
+
127
+ await expect(
128
+ getConsumerBalance({
129
+ baseUrl: 'http://api.test',
130
+ orgId: 'org_42',
131
+ apiKey: 'bk_test',
132
+ fetchImpl: fetchImpl as unknown as typeof fetch,
133
+ }),
134
+ ).rejects.toThrow('HTTP 403');
135
+ });
136
+ });
137
+
138
+ describe('createConsumerTopUp (HTTP helper)', () => {
139
+ it('POSTs JSON body with whole-cent amount string', async () => {
140
+ const fetchImpl = vi
141
+ .fn()
142
+ .mockResolvedValue(
143
+ mockResponse({
144
+ checkoutUrl: 'https://checkout.stripe.test/x',
145
+ sessionId: 'cs_test_x',
146
+ }),
147
+ );
148
+
149
+ await createConsumerTopUp({
150
+ baseUrl: 'http://api.test',
151
+ orgId: 'org_42',
152
+ apiKey: 'bk_test',
153
+ amountUsd: 19.99,
154
+ fetchImpl: fetchImpl as unknown as typeof fetch,
155
+ });
156
+
157
+ const [url, init] = fetchImpl.mock.calls[0];
158
+ const parsed = new URL(String(url));
159
+ expect(parsed.pathname).toBe('/api/v1/billing/org_42/consumer/topup');
160
+ expect(init?.method).toBe('POST');
161
+ expect(JSON.parse(String(init?.body))).toEqual({ amount: '19.99' });
162
+ const headers = init?.headers as Record<string, string>;
163
+ expect(headers['Content-Type']).toBe('application/json');
164
+ });
165
+
166
+ it('rejects non-positive amounts', async () => {
167
+ await expect(
168
+ createConsumerTopUp({
169
+ baseUrl: 'http://api.test',
170
+ orgId: 'org_42',
171
+ apiKey: 'bk_test',
172
+ amountUsd: 0,
173
+ }),
174
+ ).rejects.toThrow(/positive/);
175
+ });
176
+
177
+ it('rejects amounts below the $5 platform minimum', async () => {
178
+ await expect(
179
+ createConsumerTopUp({
180
+ baseUrl: 'http://api.test',
181
+ orgId: 'org_42',
182
+ apiKey: 'bk_test',
183
+ amountUsd: 1,
184
+ }),
185
+ ).rejects.toThrow(/at least \$5/);
186
+ });
187
+
188
+ it('throws on non-OK responses with body detail', async () => {
189
+ const fetchImpl = vi
190
+ .fn()
191
+ .mockResolvedValue(mockResponse({ error: 'amount too small' }, false, 400));
192
+
193
+ await expect(
194
+ createConsumerTopUp({
195
+ baseUrl: 'http://api.test',
196
+ orgId: 'org_42',
197
+ apiKey: 'bk_test',
198
+ amountUsd: 25,
199
+ fetchImpl: fetchImpl as unknown as typeof fetch,
200
+ }),
201
+ ).rejects.toThrow(/HTTP 400/);
202
+ });
203
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { downloadArtifact } from '../src/tools.js';
3
+ import { makeFakeDeps, makeFakeClient, makeFakeSession } from './helpers.js';
4
+
5
+ describe('download_artifact', () => {
6
+ it('returns inline text content for a text artifact when savePath is omitted', async () => {
7
+ const session = makeFakeSession({
8
+ artifacts: [
9
+ {
10
+ fileName: 'output.json',
11
+ mimeType: 'application/json',
12
+ data: new TextEncoder().encode('{"ok":true}'),
13
+ },
14
+ ],
15
+ });
16
+ const client = makeFakeClient({
17
+ task: { taskId: 't1', state: 'completed' },
18
+ session,
19
+ });
20
+ const { deps } = makeFakeDeps({ client });
21
+
22
+ const res = await downloadArtifact(
23
+ { taskId: 't1', fileName: 'output.json' },
24
+ deps,
25
+ );
26
+
27
+ expect(client.connectMock).toHaveBeenCalledWith({ taskId: 't1' });
28
+ expect(res.isError).toBeUndefined();
29
+ expect(res.content[0].text).toContain('[artifact: output.json]');
30
+ expect(res.content[0].text).toContain('{"ok":true}');
31
+ expect(session.closeMock).toHaveBeenCalledOnce();
32
+ });
33
+
34
+ it('returns base64 content for binary artifacts when savePath is omitted', async () => {
35
+ const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
36
+ const session = makeFakeSession({
37
+ artifacts: [{ fileName: 'image.png', mimeType: 'image/png', data: bytes }],
38
+ });
39
+ const client = makeFakeClient({
40
+ task: { taskId: 't2', state: 'completed' },
41
+ session,
42
+ });
43
+ const { deps } = makeFakeDeps({ client });
44
+
45
+ const res = await downloadArtifact(
46
+ { taskId: 't2', fileName: 'image.png' },
47
+ deps,
48
+ );
49
+
50
+ expect(res.isError).toBeUndefined();
51
+ expect(res.content[0].text).toContain('image/png');
52
+ expect(res.content[0].text).toContain('base64');
53
+ expect(res.content[0].text).toContain(Buffer.from(bytes).toString('base64'));
54
+ });
55
+
56
+ it('writes to disk when savePath is provided', async () => {
57
+ const bytes = new TextEncoder().encode('hello');
58
+ const session = makeFakeSession({
59
+ artifacts: [{ fileName: 'note.txt', mimeType: 'text/plain', data: bytes }],
60
+ });
61
+ const client = makeFakeClient({
62
+ task: { taskId: 't3', state: 'completed' },
63
+ session,
64
+ });
65
+ const { deps, mocks } = makeFakeDeps({ client });
66
+
67
+ const res = await downloadArtifact(
68
+ { taskId: 't3', fileName: 'note.txt', savePath: 'out/note.txt' },
69
+ deps,
70
+ );
71
+
72
+ expect(res.isError).toBeUndefined();
73
+ expect(mocks.resolveSavePath).toHaveBeenCalledWith('out/note.txt');
74
+ expect(mocks.writeFile).toHaveBeenCalledWith('out/note.txt', bytes);
75
+ expect(res.content[0].text).toContain('Saved 5 bytes to out/note.txt');
76
+ });
77
+
78
+ it('returns an error when the requested artifact is not found', async () => {
79
+ const session = makeFakeSession({
80
+ artifacts: [
81
+ {
82
+ fileName: 'other.txt',
83
+ mimeType: 'text/plain',
84
+ data: new TextEncoder().encode('x'),
85
+ },
86
+ ],
87
+ });
88
+ const client = makeFakeClient({
89
+ task: { taskId: 't4', state: 'completed' },
90
+ session,
91
+ });
92
+ const { deps } = makeFakeDeps({ client });
93
+
94
+ const res = await downloadArtifact(
95
+ { taskId: 't4', fileName: 'missing.txt' },
96
+ deps,
97
+ );
98
+
99
+ expect(res.isError).toBe(true);
100
+ expect(res.content[0].text).toContain('"missing.txt" not found');
101
+ expect(res.content[0].text).toContain('other.txt');
102
+ expect(session.closeMock).toHaveBeenCalledOnce();
103
+ });
104
+
105
+ it('uses paid TaskClient for paid tasks', async () => {
106
+ const session = makeFakeSession({
107
+ artifacts: [
108
+ {
109
+ fileName: 'a.txt',
110
+ mimeType: 'text/plain',
111
+ data: new TextEncoder().encode('a'),
112
+ },
113
+ ],
114
+ });
115
+ const client = makeFakeClient({
116
+ task: { taskId: 't5', state: 'completed', billingMode: 'paid' },
117
+ session,
118
+ });
119
+ const { deps, mocks } = makeFakeDeps({ client });
120
+
121
+ await downloadArtifact({ taskId: 't5', fileName: 'a.txt' }, deps);
122
+
123
+ expect(mocks.getTaskClient).toHaveBeenCalledWith('free');
124
+ expect(mocks.getTaskClient).toHaveBeenCalledWith('paid');
125
+ });
126
+
127
+ it('returns an error when connect fails', async () => {
128
+ const client = makeFakeClient({
129
+ task: { taskId: 't6', state: 'completed' },
130
+ });
131
+ client.connectMock.mockRejectedValueOnce(new Error('connect refused'));
132
+ const { deps } = makeFakeDeps({ client });
133
+
134
+ const res = await downloadArtifact(
135
+ { taskId: 't6', fileName: 'x.txt' },
136
+ deps,
137
+ );
138
+
139
+ expect(res.isError).toBe(true);
140
+ expect(res.content[0].text).toContain('connect refused');
141
+ });
142
+ });