@inferencesh/sdk 0.6.12 → 0.6.14

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,7 @@
1
1
  import { HttpClient } from '../http/client';
2
2
  import { StreamableManager } from '../http/streamable';
3
- import { ChatStatusBusy, ChatStatusIdle, ToolInvocationStatusAwaitingInput, ToolTypeClient, } from '../types';
3
+ import { PollManager } from '../http/poll';
4
+ import { ChatStatusBusy, ChatStatusIdle, ToolInvocationStatusAwaitingInput, ToolInvocationStatusInProgress, ToolTypeClient, } from '../types';
4
5
  import { FilesAPI } from './files';
5
6
  import { AgentsAPI } from './agents';
6
7
  const mockFetch = jest.fn();
@@ -70,6 +71,66 @@ describe('Agent.sendMessage (polling mode)', () => {
70
71
  expect(result.assistantMessage).toEqual(assistantMessage);
71
72
  expect(onChat).toHaveBeenCalledWith(expect.objectContaining({ id: 'chat-1', status: ChatStatusIdle }));
72
73
  });
74
+ it('should skip full GET /chats when poll status is unchanged', async () => {
75
+ mockJsonResponse({
76
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
77
+ assistant_message: makeMessage(),
78
+ });
79
+ mockJsonResponse({ status: ChatStatusBusy });
80
+ mockJsonResponse({
81
+ id: 'chat-1',
82
+ status: ChatStatusBusy,
83
+ chat_messages: [],
84
+ });
85
+ // Same status again — pollUntilIdle should return a stub without fetching full chat
86
+ mockJsonResponse({ status: ChatStatusBusy });
87
+ mockJsonResponse({ status: ChatStatusIdle });
88
+ mockJsonResponse({
89
+ id: 'chat-1',
90
+ status: ChatStatusIdle,
91
+ chat_messages: [],
92
+ });
93
+ await agent().sendMessage('hello', { stream: false });
94
+ const fullChatGets = mockFetch.mock.calls.filter(([url, init]) => String(url).includes('/chats/chat-1') &&
95
+ !String(url).includes('/status') &&
96
+ !String(url).includes('/stop') &&
97
+ init.method === 'GET');
98
+ expect(fullChatGets).toHaveLength(2);
99
+ });
100
+ it('should dispatch onToolCall for in_progress client tool invocations', async () => {
101
+ const toolInvocation = {
102
+ id: 'tool-inv-progress',
103
+ type: ToolTypeClient,
104
+ status: ToolInvocationStatusInProgress,
105
+ function: { name: 'my_tool', arguments: { x: 2 } },
106
+ };
107
+ const messageWithTool = makeMessage({ tool_invocations: [toolInvocation] });
108
+ mockJsonResponse({
109
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
110
+ assistant_message: makeMessage(),
111
+ });
112
+ mockJsonResponse({ status: ChatStatusBusy });
113
+ mockJsonResponse({
114
+ id: 'chat-1',
115
+ status: ChatStatusBusy,
116
+ chat_messages: [messageWithTool],
117
+ });
118
+ mockJsonResponse({ status: ChatStatusIdle });
119
+ mockJsonResponse({
120
+ id: 'chat-1',
121
+ status: ChatStatusIdle,
122
+ chat_messages: [messageWithTool],
123
+ });
124
+ const onMessage = jest.fn();
125
+ const onToolCall = jest.fn();
126
+ await agent().sendMessage('run tool', { stream: false, onMessage, onToolCall });
127
+ expect(onToolCall).toHaveBeenCalledTimes(1);
128
+ expect(onToolCall).toHaveBeenCalledWith({
129
+ id: 'tool-inv-progress',
130
+ name: 'my_tool',
131
+ args: { x: 2 },
132
+ });
133
+ });
73
134
  it('should dispatch onToolCall once per client tool invocation', async () => {
74
135
  const toolInvocation = {
75
136
  id: 'tool-inv-1',
@@ -104,6 +165,30 @@ describe('Agent.sendMessage (polling mode)', () => {
104
165
  args: { x: 1 },
105
166
  });
106
167
  });
168
+ it('should call onMessage for each chat message during polling', async () => {
169
+ const msg1 = makeMessage({ id: 'msg-1', content: 'first' });
170
+ const msg2 = makeMessage({ id: 'msg-2', content: 'second' });
171
+ mockJsonResponse({
172
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
173
+ assistant_message: makeMessage(),
174
+ });
175
+ mockJsonResponse({ status: ChatStatusBusy });
176
+ mockJsonResponse({
177
+ id: 'chat-1',
178
+ status: ChatStatusBusy,
179
+ chat_messages: [msg1, msg2],
180
+ });
181
+ mockJsonResponse({ status: ChatStatusIdle });
182
+ mockJsonResponse({
183
+ id: 'chat-1',
184
+ status: ChatStatusIdle,
185
+ chat_messages: [msg1, msg2],
186
+ });
187
+ const onMessage = jest.fn();
188
+ await agent().sendMessage('hello', { stream: false, onMessage });
189
+ expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ id: 'msg-1', content: 'first' }));
190
+ expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ id: 'msg-2', content: 'second' }));
191
+ });
107
192
  it('should return chat output from run() after polling completes', async () => {
108
193
  const userMessage = makeMessage({ id: 'user-1', role: 'user' });
109
194
  const assistantMessage = makeMessage();
@@ -124,6 +209,43 @@ describe('Agent.sendMessage (polling mode)', () => {
124
209
  const output = await agent().run('compute');
125
210
  expect(output).toEqual({ answer: 42 });
126
211
  });
212
+ it('should return null from run() when the chat has no finish output', async () => {
213
+ mockJsonResponse({
214
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
215
+ assistant_message: makeMessage(),
216
+ });
217
+ mockJsonResponse({ status: ChatStatusBusy });
218
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy });
219
+ mockJsonResponse({ status: ChatStatusIdle });
220
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle });
221
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, output: null });
222
+ const output = await agent().run('no finish tool');
223
+ expect(output).toBeNull();
224
+ });
225
+ it('should warn on repeated poll errors without resolving sendMessage', async () => {
226
+ jest.useFakeTimers();
227
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
228
+ mockJsonResponse({
229
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
230
+ assistant_message: makeMessage(),
231
+ });
232
+ mockFetch.mockRejectedValue(new Error('status endpoint down'));
233
+ const sendPromise = agent().sendMessage('hello', { stream: false });
234
+ for (let i = 0; i < 6; i++) {
235
+ await Promise.resolve();
236
+ jest.advanceTimersByTime(20);
237
+ await Promise.resolve();
238
+ }
239
+ expect(warnSpy).toHaveBeenCalledWith('[Agent] Poll error:', expect.any(Error));
240
+ let settled = false;
241
+ sendPromise.then(() => {
242
+ settled = true;
243
+ });
244
+ await Promise.resolve();
245
+ expect(settled).toBe(false);
246
+ warnSpy.mockRestore();
247
+ jest.useRealTimers();
248
+ });
127
249
  });
128
250
  describe('Agent.sendMessage (streaming mode)', () => {
129
251
  beforeEach(() => {
@@ -156,6 +278,39 @@ describe('Agent.sendMessage (streaming mode)', () => {
156
278
  expect(result.userMessage).toEqual(userMessage);
157
279
  expect(onChat).toHaveBeenCalledWith(expect.objectContaining({ id: 'chat-1', status: ChatStatusIdle }));
158
280
  });
281
+ it('should dispatch onToolCall for in_progress tools from chat_messages stream events', async () => {
282
+ const toolInvocation = {
283
+ id: 'tool-inv-progress',
284
+ type: ToolTypeClient,
285
+ status: ToolInvocationStatusInProgress,
286
+ function: { name: 'my_tool', arguments: { x: 2 } },
287
+ };
288
+ const messageWithTool = makeMessage({ tool_invocations: [toolInvocation] });
289
+ mockFetch.mockImplementation((url) => {
290
+ if (url.includes('/agents/run')) {
291
+ return Promise.resolve({
292
+ ok: true,
293
+ status: 200,
294
+ text: () => Promise.resolve(JSON.stringify({
295
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
296
+ assistant_message: makeMessage(),
297
+ })),
298
+ });
299
+ }
300
+ return Promise.resolve(mockNdjsonStream([
301
+ `${JSON.stringify({ event: 'chat_messages', data: messageWithTool })}\n`,
302
+ `${JSON.stringify({ event: 'chats', data: { id: 'chat-1', status: ChatStatusIdle } })}\n`,
303
+ ]));
304
+ });
305
+ const onToolCall = jest.fn();
306
+ await streamingAgent().sendMessage('run tool', { onToolCall });
307
+ expect(onToolCall).toHaveBeenCalledTimes(1);
308
+ expect(onToolCall).toHaveBeenCalledWith({
309
+ id: 'tool-inv-progress',
310
+ name: 'my_tool',
311
+ args: { x: 2 },
312
+ });
313
+ });
159
314
  it('should dispatch onToolCall from chat_messages stream events', async () => {
160
315
  const toolInvocation = {
161
316
  id: 'tool-inv-1',
@@ -189,6 +344,19 @@ describe('Agent.sendMessage (streaming mode)', () => {
189
344
  args: { x: 1 },
190
345
  });
191
346
  });
347
+ it('should return immediately without waiting when stream is true and no callbacks', async () => {
348
+ const userMessage = makeMessage({ id: 'user-1', role: 'user' });
349
+ const assistantMessage = makeMessage({ id: 'asst-1' });
350
+ mockJsonResponse({
351
+ user_message: userMessage,
352
+ assistant_message: assistantMessage,
353
+ });
354
+ const result = await streamingAgent().sendMessage('hello');
355
+ expect(result.userMessage).toEqual(userMessage);
356
+ expect(result.assistantMessage).toEqual(assistantMessage);
357
+ expect(mockFetch).toHaveBeenCalledTimes(1);
358
+ expect(mockFetch.mock.calls[0][0]).toContain('/agents/run');
359
+ });
192
360
  it('should open the stream before POST when continuing an existing chat', async () => {
193
361
  const http = new HttpClient({
194
362
  apiKey: 'test-key',
@@ -281,6 +449,79 @@ describe('Agent.sendMessage (file attachments)', () => {
281
449
  expect(body.input.files).toEqual(['inf://files/doc']);
282
450
  expect(mockFetch.mock.calls.filter(([url]) => String(url).includes('/files')).length).toBe(0);
283
451
  });
452
+ it('should upload Blob attachments before POST /agents/run', async () => {
453
+ const fileRecord = {
454
+ id: 'file-blob',
455
+ uri: 'inf://files/blob-direct',
456
+ upload_url: 'https://upload.example.com/put',
457
+ content_type: 'image/png',
458
+ };
459
+ mockJsonResponse([fileRecord]);
460
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
461
+ mockJsonResponse({
462
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
463
+ assistant_message: makeMessage(),
464
+ });
465
+ mockJsonResponse({ status: ChatStatusBusy });
466
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
467
+ mockJsonResponse({ status: ChatStatusIdle });
468
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, chat_messages: [] });
469
+ const blob = new Blob(['png-bytes'], { type: 'image/png' });
470
+ await agent().sendMessage('see image', { stream: false, files: [blob] });
471
+ const fileCreateCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/files') && !String(url).includes('upload.example.com'));
472
+ expect(fileCreateCall).toBeDefined();
473
+ const runCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/agents/run'));
474
+ const body = JSON.parse(String(runCall[1].body));
475
+ expect(body.input.images).toEqual(['inf://files/blob-direct']);
476
+ expect(body.input.files).toBeUndefined();
477
+ });
478
+ });
479
+ describe('Agent.sendMessage (template ref)', () => {
480
+ beforeEach(() => {
481
+ jest.clearAllMocks();
482
+ });
483
+ const templateAgent = (context) => {
484
+ const http = new HttpClient({
485
+ apiKey: 'test-key',
486
+ stream: false,
487
+ pollIntervalMs: 20,
488
+ });
489
+ return new AgentsAPI(http, new FilesAPI(http)).create('inference/my-agent', { context });
490
+ };
491
+ function mockRunAndPoll() {
492
+ mockJsonResponse({
493
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
494
+ assistant_message: makeMessage(),
495
+ });
496
+ mockJsonResponse({ status: ChatStatusBusy });
497
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
498
+ mockJsonResponse({ status: ChatStatusIdle });
499
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, chat_messages: [] });
500
+ }
501
+ it('should POST agent ref and context to /agents/run', async () => {
502
+ mockRunAndPoll();
503
+ await templateAgent({ tenant: 'acme' }).sendMessage('hello', { stream: false });
504
+ const runCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/agents/run'));
505
+ const body = JSON.parse(String(runCall[1].body));
506
+ expect(body.agent).toBe('inference/my-agent');
507
+ expect(body.agent_config).toBeUndefined();
508
+ expect(body.context).toEqual({ tenant: 'acme' });
509
+ expect(body.chat_id).toBeNull();
510
+ expect(body.input.text).toBe('hello');
511
+ });
512
+ it('should include chat_id on follow-up messages', async () => {
513
+ const agentInstance = templateAgent();
514
+ mockRunAndPoll();
515
+ await agentInstance.sendMessage('first', { stream: false });
516
+ jest.clearAllMocks();
517
+ mockRunAndPoll();
518
+ await agentInstance.sendMessage('second', { stream: false });
519
+ const runCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/agents/run'));
520
+ const body = JSON.parse(String(runCall[1].body));
521
+ expect(body.agent).toBe('inference/my-agent');
522
+ expect(body.chat_id).toBe('chat-1');
523
+ expect(body.input.text).toBe('second');
524
+ });
284
525
  });
285
526
  describe('Agent.sendMessage (ad-hoc config)', () => {
286
527
  beforeEach(() => {
@@ -319,6 +560,31 @@ describe('Agent.sendMessage (ad-hoc config)', () => {
319
560
  expect(body.agent_name).toBe('adhoc-bot');
320
561
  expect(body.input.text).toBe('hello');
321
562
  });
563
+ it('should prefer AgentOptions.name over config.name for agent_name', async () => {
564
+ const http = new HttpClient({
565
+ apiKey: 'test-key',
566
+ stream: false,
567
+ pollIntervalMs: 20,
568
+ });
569
+ const namedAgent = new AgentsAPI(http, new FilesAPI(http)).create({
570
+ core_app: { ref: 'openrouter/claude@latest' },
571
+ system_prompt: 'You are helpful',
572
+ name: 'config-name',
573
+ }, { name: 'override-name' });
574
+ mockJsonResponse({
575
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
576
+ assistant_message: makeMessage(),
577
+ });
578
+ mockJsonResponse({ status: ChatStatusBusy });
579
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
580
+ mockJsonResponse({ status: ChatStatusIdle });
581
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, chat_messages: [] });
582
+ await namedAgent.sendMessage('hello', { stream: false });
583
+ const runCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/agents/run'));
584
+ const body = JSON.parse(String(runCall[1].body));
585
+ expect(body.agent_name).toBe('override-name');
586
+ expect(body.agent_config.name).toBe('config-name');
587
+ });
322
588
  });
323
589
  describe('Agent lifecycle', () => {
324
590
  beforeEach(() => {
@@ -356,6 +622,28 @@ describe('Agent lifecycle', () => {
356
622
  await agentInstance.stopChat();
357
623
  expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/chats/chat-1/stop'), expect.anything());
358
624
  });
625
+ it('disconnect should stop active poll managers', async () => {
626
+ jest.useFakeTimers();
627
+ const stopSpy = jest.spyOn(PollManager.prototype, 'stop');
628
+ const agentInstance = agent();
629
+ mockJsonResponse({
630
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
631
+ assistant_message: makeMessage(),
632
+ });
633
+ mockJsonResponse({ status: ChatStatusBusy });
634
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
635
+ const sendPromise = agentInstance.sendMessage('hello', {
636
+ stream: false,
637
+ pollIntervalMs: 5000,
638
+ });
639
+ await Promise.resolve();
640
+ await jest.advanceTimersByTimeAsync(0);
641
+ agentInstance.disconnect();
642
+ expect(stopSpy).toHaveBeenCalled();
643
+ stopSpy.mockRestore();
644
+ jest.useRealTimers();
645
+ sendPromise.catch(() => undefined);
646
+ });
359
647
  it('disconnect should stop active stream managers', async () => {
360
648
  const http = new HttpClient({
361
649
  apiKey: 'test-key',
@@ -436,6 +724,64 @@ describe('Agent lifecycle', () => {
436
724
  await agentInstance.stopChat();
437
725
  expect(mockFetch).not.toHaveBeenCalled();
438
726
  });
727
+ it('reset should allow onToolCall to fire again for the same invocation id', async () => {
728
+ const toolInvocation = {
729
+ id: 'tool-inv-reset',
730
+ type: ToolTypeClient,
731
+ status: ToolInvocationStatusAwaitingInput,
732
+ function: { name: 'my_tool', arguments: { x: 1 } },
733
+ };
734
+ const messageWithTool = makeMessage({ tool_invocations: [toolInvocation] });
735
+ const agentInstance = agent();
736
+ const onMessage = jest.fn();
737
+ const onToolCall = jest.fn();
738
+ mockJsonResponse({
739
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
740
+ assistant_message: makeMessage(),
741
+ });
742
+ mockJsonResponse({ status: ChatStatusBusy });
743
+ mockJsonResponse({
744
+ id: 'chat-1',
745
+ status: ChatStatusBusy,
746
+ chat_messages: [messageWithTool],
747
+ });
748
+ mockJsonResponse({ status: ChatStatusBusy });
749
+ mockJsonResponse({ status: ChatStatusIdle });
750
+ mockJsonResponse({
751
+ id: 'chat-1',
752
+ status: ChatStatusIdle,
753
+ chat_messages: [messageWithTool],
754
+ });
755
+ await agentInstance.sendMessage('run tool', { stream: false, onMessage, onToolCall });
756
+ expect(onToolCall).toHaveBeenCalledTimes(1);
757
+ agentInstance.reset();
758
+ jest.clearAllMocks();
759
+ onToolCall.mockClear();
760
+ mockJsonResponse({
761
+ user_message: makeMessage({ id: 'user-2', role: 'user' }),
762
+ assistant_message: makeMessage({ id: 'asst-2' }),
763
+ });
764
+ mockJsonResponse({ status: ChatStatusBusy });
765
+ mockJsonResponse({
766
+ id: 'chat-1',
767
+ status: ChatStatusBusy,
768
+ chat_messages: [messageWithTool],
769
+ });
770
+ mockJsonResponse({ status: ChatStatusBusy });
771
+ mockJsonResponse({ status: ChatStatusIdle });
772
+ mockJsonResponse({
773
+ id: 'chat-1',
774
+ status: ChatStatusIdle,
775
+ chat_messages: [messageWithTool],
776
+ });
777
+ await agentInstance.sendMessage('run tool again', { stream: false, onMessage, onToolCall });
778
+ expect(onToolCall).toHaveBeenCalledTimes(1);
779
+ expect(onToolCall).toHaveBeenCalledWith({
780
+ id: 'tool-inv-reset',
781
+ name: 'my_tool',
782
+ args: { x: 1 },
783
+ });
784
+ });
439
785
  });
440
786
  describe('Agent.getChat', () => {
441
787
  beforeEach(() => {
@@ -459,11 +805,55 @@ describe('Agent.getChat', () => {
459
805
  expect(url).toContain('/chats/chat-42');
460
806
  expect(init.method).toBe('GET');
461
807
  });
808
+ it('should expose currentChatId after sendMessage establishes a chat', async () => {
809
+ const agentInstance = agent();
810
+ mockJsonResponse({
811
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
812
+ assistant_message: makeMessage(),
813
+ });
814
+ mockJsonResponse({ status: ChatStatusBusy });
815
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
816
+ mockJsonResponse({ status: ChatStatusIdle });
817
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, chat_messages: [] });
818
+ expect(agentInstance.currentChatId).toBeNull();
819
+ await agentInstance.sendMessage('hello', { stream: false });
820
+ expect(agentInstance.currentChatId).toBe('chat-1');
821
+ });
822
+ it('should GET /chats/{id} with established chat id when chatId is omitted', async () => {
823
+ const agentInstance = agent();
824
+ mockJsonResponse({
825
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
826
+ assistant_message: makeMessage(),
827
+ });
828
+ mockJsonResponse({ status: ChatStatusBusy });
829
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
830
+ mockJsonResponse({ status: ChatStatusIdle });
831
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, chat_messages: [] });
832
+ await agentInstance.sendMessage('hello', { stream: false });
833
+ jest.clearAllMocks();
834
+ const chat = { id: 'chat-1', status: 'idle', chat_messages: [] };
835
+ mockJsonResponse(chat);
836
+ const result = await agentInstance.getChat();
837
+ expect(result).toEqual(chat);
838
+ const [url, init] = mockFetch.mock.calls[0];
839
+ expect(url).toContain('/chats/chat-1');
840
+ expect(init.method).toBe('GET');
841
+ });
462
842
  });
463
843
  describe('Agent.submitToolResult', () => {
464
844
  beforeEach(() => {
465
845
  jest.clearAllMocks();
466
846
  });
847
+ it('should POST plain string results to /tools/{invocationId}', async () => {
848
+ const http = new HttpClient({ apiKey: 'test-key' });
849
+ const agentInstance = new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
850
+ mockJsonResponse(null);
851
+ await agentInstance.submitToolResult('inv-plain', 'done');
852
+ const [url, init] = mockFetch.mock.calls[0];
853
+ expect(url).toContain('/tools/inv-plain');
854
+ expect(init.method).toBe('POST');
855
+ expect(JSON.parse(String(init.body))).toEqual({ result: 'done' });
856
+ });
467
857
  it('should JSON-stringify structured action results', async () => {
468
858
  const http = new HttpClient({ apiKey: 'test-key' });
469
859
  const agentInstance = new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
@@ -512,6 +902,20 @@ describe('AgentsAPI (template CRUD)', () => {
512
902
  expect(url).toContain('/agents/internal-tools');
513
903
  expect(init.method).toBe('GET');
514
904
  });
905
+ it('should GET /agents/{id}/card for getA2ACard()', async () => {
906
+ const card = {
907
+ name: 'support-bot',
908
+ description: 'Customer support agent',
909
+ url: 'https://api.example.com/agents/support-bot',
910
+ version: '1.0.0',
911
+ };
912
+ mockJsonResponse(card);
913
+ const result = await api().getA2ACard('agent-1');
914
+ expect(result).toEqual(card);
915
+ const [url, init] = mockFetch.mock.calls[0];
916
+ expect(url).toContain('/agents/agent-1/card');
917
+ expect(init.method).toBe('GET');
918
+ });
515
919
  it('should POST team_id for transferOwnership()', async () => {
516
920
  const agent = { id: 'agent-1' };
517
921
  mockJsonResponse(agent);
@@ -41,6 +41,14 @@ describe('FilesAPI', () => {
41
41
  expect(init.method).toBe('DELETE');
42
42
  });
43
43
  describe('processInput', () => {
44
+ it('should return falsy inputs unchanged without uploading', async () => {
45
+ await expect(api().processInput(null)).resolves.toBeNull();
46
+ await expect(api().processInput(undefined)).resolves.toBeUndefined();
47
+ await expect(api().processInput(0)).resolves.toBe(0);
48
+ await expect(api().processInput(false)).resolves.toBe(false);
49
+ await expect(api().processInput('')).resolves.toBe('');
50
+ expect(mockFetch).not.toHaveBeenCalled();
51
+ });
44
52
  it('should not treat short plain strings as base64 file uploads', async () => {
45
53
  const result = await api().processInput({ key: 'key1', note: 'hello' });
46
54
  expect(result).toEqual({ key: 'key1', note: 'hello' });
@@ -50,6 +50,55 @@ describe('KnowledgeAPI', () => {
50
50
  expect(url).toContain('/knowledge/know-1/transfer');
51
51
  expect(JSON.parse(init.body)).toEqual({ team_id: 'team-5' });
52
52
  });
53
+ it('should GET /knowledge/{id} for get()', async () => {
54
+ const entry = { id: 'know-1', name: 'docs' };
55
+ mockJsonResponse(entry);
56
+ const result = await api().get('know-1');
57
+ expect(result).toEqual(entry);
58
+ const [url, init] = mockFetch.mock.calls[0];
59
+ expect(url).toContain('/knowledge/know-1');
60
+ expect(init.method).toBe('GET');
61
+ });
62
+ it('should POST /knowledge/{id} for update()', async () => {
63
+ const entry = { id: 'know-1', name: 'updated' };
64
+ mockJsonResponse(entry);
65
+ const result = await api().update('know-1', { name: 'updated' });
66
+ expect(result).toEqual(entry);
67
+ const [, init] = mockFetch.mock.calls[0];
68
+ expect(JSON.parse(init.body)).toEqual({ name: 'updated' });
69
+ });
70
+ it('should DELETE /knowledge/{id} for delete()', async () => {
71
+ mockJsonResponse(null);
72
+ await api().delete('know-1');
73
+ const [url, init] = mockFetch.mock.calls[0];
74
+ expect(url).toContain('/knowledge/know-1');
75
+ expect(init.method).toBe('DELETE');
76
+ });
77
+ it('should POST /knowledge/{id}/versions/list for listVersions()', async () => {
78
+ const page = { items: [{ id: 'ver-1' }], next_cursor: null };
79
+ mockJsonResponse(page);
80
+ const result = await api().listVersions('know-1', { limit: 10 });
81
+ expect(result).toEqual(page);
82
+ const [url, init] = mockFetch.mock.calls[0];
83
+ expect(url).toContain('/knowledge/know-1/versions/list');
84
+ expect(JSON.parse(init.body)).toEqual({ limit: 10 });
85
+ });
86
+ it('should GET /knowledge/{id}/versions/{versionId} for getVersion()', async () => {
87
+ const version = { id: 'ver-1', knowledge_id: 'know-1' };
88
+ mockJsonResponse(version);
89
+ const result = await api().getVersion('know-1', 'ver-1');
90
+ expect(result).toEqual(version);
91
+ const [url, init] = mockFetch.mock.calls[0];
92
+ expect(url).toContain('/knowledge/know-1/versions/ver-1');
93
+ expect(init.method).toBe('GET');
94
+ });
95
+ it('should POST visibility for updateVisibility()', async () => {
96
+ const entry = { id: 'know-1', visibility: 'team' };
97
+ mockJsonResponse(entry);
98
+ await api().updateVisibility('know-1', 'team');
99
+ const [, init] = mockFetch.mock.calls[0];
100
+ expect(JSON.parse(init.body)).toEqual({ visibility: 'team' });
101
+ });
53
102
  });
54
103
  describe('SkillsAPI', () => {
55
104
  beforeEach(() => {
@@ -91,4 +140,99 @@ describe('SkillsAPI', () => {
91
140
  expect(url).toContain('/store/skills/list');
92
141
  expect(init.method).toBe('POST');
93
142
  });
143
+ it('should GET /skills/resolve with ref only when skill is omitted', async () => {
144
+ const resolved = { ref: 'acme/skill@v1' };
145
+ mockJsonResponse(resolved);
146
+ const result = await api().resolve('acme/skill@v1');
147
+ expect(result).toEqual(resolved);
148
+ const [url] = mockFetch.mock.calls[0];
149
+ expect(url).toContain('/skills/resolve');
150
+ expect(url).toContain('ref=acme');
151
+ expect(url).not.toContain('skill=');
152
+ });
153
+ it('should GET /skills/{namespace}/{name}/content for getContent()', async () => {
154
+ mockJsonResponse({ body: '# Skill instructions' });
155
+ const result = await api().getContent('acme', 'research');
156
+ expect(result).toEqual({ body: '# Skill instructions' });
157
+ const [url, init] = mockFetch.mock.calls[0];
158
+ expect(url).toContain('/skills/acme/research/content');
159
+ expect(init.method).toBe('GET');
160
+ });
161
+ it('should POST team_id for transferOwnership()', async () => {
162
+ const skill = { id: 'skill-1' };
163
+ mockJsonResponse(skill);
164
+ await api().transferOwnership('skill-1', 'team-8');
165
+ const [url, init] = mockFetch.mock.calls[0];
166
+ expect(url).toContain('/skills/skill-1/transfer');
167
+ expect(JSON.parse(init.body)).toEqual({ team_id: 'team-8' });
168
+ });
169
+ it('should POST /skills/list for list()', async () => {
170
+ const page = { items: [{ id: 'skill-1' }], next_cursor: null };
171
+ mockJsonResponse(page);
172
+ const result = await api().list({ limit: 10 });
173
+ expect(result).toEqual(page);
174
+ const [url, init] = mockFetch.mock.calls[0];
175
+ expect(url).toContain('/skills/list');
176
+ expect(init.method).toBe('POST');
177
+ });
178
+ it('should GET /skills/{id} for get()', async () => {
179
+ const skill = { id: 'skill-1', name: 'research' };
180
+ mockJsonResponse(skill);
181
+ const result = await api().get('skill-1');
182
+ expect(result).toEqual(skill);
183
+ const [url, init] = mockFetch.mock.calls[0];
184
+ expect(url).toContain('/skills/skill-1');
185
+ expect(init.method).toBe('GET');
186
+ });
187
+ it('should POST /skills for create()', async () => {
188
+ const payload = { namespace: 'acme', name: 'research' };
189
+ const skill = { id: 'skill-new', ...payload };
190
+ mockJsonResponse(skill);
191
+ const result = await api().create(payload);
192
+ expect(result).toEqual(skill);
193
+ const [url, init] = mockFetch.mock.calls[0];
194
+ expect(url).toContain('/skills');
195
+ expect(init.method).toBe('POST');
196
+ expect(JSON.parse(init.body)).toEqual(payload);
197
+ });
198
+ it('should POST /skills/{id} for update()', async () => {
199
+ const skill = { id: 'skill-1', name: 'updated' };
200
+ mockJsonResponse(skill);
201
+ const result = await api().update('skill-1', { name: 'updated' });
202
+ expect(result).toEqual(skill);
203
+ const [, init] = mockFetch.mock.calls[0];
204
+ expect(JSON.parse(init.body)).toEqual({ name: 'updated' });
205
+ });
206
+ it('should DELETE /skills/{id} for delete()', async () => {
207
+ mockJsonResponse(null);
208
+ await api().delete('skill-1');
209
+ const [url, init] = mockFetch.mock.calls[0];
210
+ expect(url).toContain('/skills/skill-1');
211
+ expect(init.method).toBe('DELETE');
212
+ });
213
+ it('should POST /skills/{id}/versions/list for listVersions()', async () => {
214
+ const page = { items: [{ id: 'ver-1' }], next_cursor: null };
215
+ mockJsonResponse(page);
216
+ const result = await api().listVersions('skill-1', { limit: 5 });
217
+ expect(result).toEqual(page);
218
+ const [url, init] = mockFetch.mock.calls[0];
219
+ expect(url).toContain('/skills/skill-1/versions/list');
220
+ expect(JSON.parse(init.body)).toEqual({ limit: 5 });
221
+ });
222
+ it('should GET /skills/{id}/versions/{versionId} for getVersion()', async () => {
223
+ const version = { id: 'ver-1', skill_id: 'skill-1' };
224
+ mockJsonResponse(version);
225
+ const result = await api().getVersion('skill-1', 'ver-1');
226
+ expect(result).toEqual(version);
227
+ const [url, init] = mockFetch.mock.calls[0];
228
+ expect(url).toContain('/skills/skill-1/versions/ver-1');
229
+ expect(init.method).toBe('GET');
230
+ });
231
+ it('should POST visibility for updateVisibility()', async () => {
232
+ const skill = { id: 'skill-1', visibility: 'team' };
233
+ mockJsonResponse(skill);
234
+ await api().updateVisibility('skill-1', 'team');
235
+ const [, init] = mockFetch.mock.calls[0];
236
+ expect(JSON.parse(init.body)).toEqual({ visibility: 'team' });
237
+ });
94
238
  });