@inferencesh/sdk 0.6.12 → 0.6.13

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,4 +1,4 @@
1
- import { ChatStatusBusy, ToolInvocationStatusAwaitingInput, ToolTypeClient, } from '../types';
1
+ import { ChatStatusBusy, ToolInvocationStatusAwaitingInput, ToolInvocationStatusInProgress, ToolTypeClient, } from '../types';
2
2
  import { createActions } from './actions';
3
3
  import * as agentApi from './api';
4
4
  import { PollManager } from '../http/poll';
@@ -94,6 +94,12 @@ describe('createActions', () => {
94
94
  mockAgentApi.approveTool.mockResolvedValue(undefined);
95
95
  mockAgentApi.rejectTool.mockResolvedValue(undefined);
96
96
  mockAgentApi.alwaysAllowTool.mockResolvedValue(undefined);
97
+ mockAgentApi.uploadFile.mockResolvedValue({
98
+ id: 'file-1',
99
+ uri: 'inf://files/uploaded',
100
+ filename: 'notes.txt',
101
+ content_type: 'text/plain',
102
+ });
97
103
  });
98
104
  describe('updateMessage (via stream listeners)', () => {
99
105
  it('should ignore messages for a different chat when IDs do not prefix-match', async () => {
@@ -141,6 +147,30 @@ describe('createActions', () => {
141
147
  expect(handler).toHaveBeenCalledWith({ x: 1 });
142
148
  expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-inv-ok', 'tool ok');
143
149
  });
150
+ it('should dispatch client tool handlers when status is in_progress', async () => {
151
+ const handler = jest.fn().mockResolvedValue('in progress ok');
152
+ const { ctx } = createTestContext({
153
+ getClientToolHandlers: () => new Map([['my_tool', handler]]),
154
+ });
155
+ const { internalActions } = createActions(ctx);
156
+ internalActions.streamChat('chat-full-id-123');
157
+ await Promise.resolve();
158
+ const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
159
+ onMessage(makeMessage({
160
+ chat_id: 'chat-short',
161
+ tool_invocations: [
162
+ {
163
+ id: 'tool-inv-progress',
164
+ type: ToolTypeClient,
165
+ status: ToolInvocationStatusInProgress,
166
+ function: { name: 'my_tool', arguments: { y: 2 } },
167
+ },
168
+ ],
169
+ }));
170
+ await Promise.resolve();
171
+ expect(handler).toHaveBeenCalledWith({ y: 2 });
172
+ expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-inv-progress', 'in progress ok');
173
+ });
144
174
  it('should submit a JSON error when a client tool handler throws', async () => {
145
175
  const handler = jest.fn().mockRejectedValue(new Error('handler boom'));
146
176
  const { ctx } = createTestContext({
@@ -216,6 +246,49 @@ describe('createActions', () => {
216
246
  // Only the explicit stopStream dispatch, not a second from onEnd
217
247
  expect(idleDispatches).toHaveLength(1);
218
248
  });
249
+ it('should clear the manager ref before poll stop so onStop does not double-dispatch idle', async () => {
250
+ const onStatusChange = jest.fn();
251
+ const { ctx, dispatch, setStreamManager } = createTestContext({
252
+ getStreamEnabled: () => false,
253
+ callbacks: { onStatusChange },
254
+ });
255
+ const { internalActions } = createActions(ctx);
256
+ internalActions.streamChat('chat-full-id-123');
257
+ await Promise.resolve();
258
+ const manager = pollInstances[0];
259
+ internalActions.stopStream();
260
+ expect(setStreamManager).toHaveBeenCalledWith(undefined);
261
+ expect(manager.stop).toHaveBeenCalled();
262
+ manager.options.onStop?.();
263
+ const idleDispatches = dispatch.mock.calls.filter(([action]) => action.type === 'SET_CONNECTION_STATUS' && action.payload === 'idle');
264
+ expect(idleDispatches).toHaveLength(1);
265
+ expect(onStatusChange).toHaveBeenCalledWith('idle');
266
+ });
267
+ });
268
+ describe('stream and poll error callbacks', () => {
269
+ it('should forward stream errors to onError callback', async () => {
270
+ const onError = jest.fn();
271
+ const { ctx } = createTestContext({ callbacks: { onError } });
272
+ const { internalActions } = createActions(ctx);
273
+ internalActions.streamChat('chat-full-id-123');
274
+ await Promise.resolve();
275
+ const streamError = new Error('stream connection lost');
276
+ streamInstances[0].options.onError?.(streamError);
277
+ expect(onError).toHaveBeenCalledWith(streamError);
278
+ });
279
+ it('should forward poll manager errors to onError callback', async () => {
280
+ const onError = jest.fn();
281
+ const { ctx } = createTestContext({
282
+ getStreamEnabled: () => false,
283
+ callbacks: { onError },
284
+ });
285
+ const { internalActions } = createActions(ctx);
286
+ internalActions.streamChat('chat-full-id-123');
287
+ await Promise.resolve();
288
+ const pollError = new Error('poll transport failed');
289
+ pollInstances[0].options.onError?.(pollError);
290
+ expect(onError).toHaveBeenCalledWith(pollError);
291
+ });
219
292
  });
220
293
  describe('publicActions.sendMessage', () => {
221
294
  it('should call onChatCreated and start streaming for a new chat', async () => {
@@ -265,6 +338,19 @@ describe('createActions', () => {
265
338
  await publicActions.sendMessage(' ');
266
339
  expect(mockAgentApi.sendMessage).not.toHaveBeenCalled();
267
340
  });
341
+ it('should no-op when agent config is missing', async () => {
342
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { });
343
+ const { ctx, dispatch } = createTestContext({ getConfig: () => null });
344
+ const { publicActions } = createActions(ctx);
345
+ await publicActions.sendMessage('hello');
346
+ expect(mockAgentApi.sendMessage).not.toHaveBeenCalled();
347
+ expect(dispatch).not.toHaveBeenCalledWith({
348
+ type: 'SET_CONNECTION_STATUS',
349
+ payload: 'streaming',
350
+ });
351
+ expect(consoleError).toHaveBeenCalledWith('[AgentSDK] No agent config provided');
352
+ consoleError.mockRestore();
353
+ });
268
354
  });
269
355
  describe('streamChat error handling', () => {
270
356
  it('should reset to idle when initial fetchChat fails', async () => {
@@ -348,6 +434,77 @@ describe('createActions', () => {
348
434
  await Promise.resolve();
349
435
  expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'poll fetch failed' }));
350
436
  });
437
+ it('should not refetch chat when poll status is unchanged', async () => {
438
+ const { ctx: baseCtx } = createTestContext({ getStreamEnabled: () => false });
439
+ const { ctx } = createTestContext({
440
+ getStreamEnabled: () => false,
441
+ client: {
442
+ ...baseCtx.client,
443
+ http: { ...baseCtx.client.http, request: jest.fn().mockResolvedValue({ status: ChatStatusBusy }) },
444
+ },
445
+ });
446
+ const { internalActions } = createActions(ctx);
447
+ mockAgentApi.fetchChat.mockResolvedValue({
448
+ id: 'chat-full-id-123',
449
+ status: ChatStatusBusy,
450
+ chat_messages: [],
451
+ });
452
+ internalActions.streamChat('chat-full-id-123');
453
+ await Promise.resolve();
454
+ await pollInstances[0].options.onData?.({ status: ChatStatusBusy });
455
+ await Promise.resolve();
456
+ const fetchCountAfterFirst = mockAgentApi.fetchChat.mock.calls.length;
457
+ await pollInstances[0].options.onData?.({ status: ChatStatusBusy });
458
+ await Promise.resolve();
459
+ expect(mockAgentApi.fetchChat).toHaveBeenCalledTimes(fetchCountAfterFirst);
460
+ });
461
+ it('should dispatch client tools from poll path when status changes', async () => {
462
+ const handler = jest.fn().mockResolvedValue('poll ok');
463
+ const toolMessage = makeMessage({
464
+ chat_id: 'chat-short',
465
+ tool_invocations: [
466
+ {
467
+ id: 'tool-inv-poll',
468
+ type: ToolTypeClient,
469
+ status: ToolInvocationStatusInProgress,
470
+ function: { name: 'my_tool', arguments: { y: 2 } },
471
+ },
472
+ ],
473
+ });
474
+ const { ctx: baseCtx } = createTestContext({
475
+ getStreamEnabled: () => false,
476
+ getClientToolHandlers: () => new Map([['my_tool', handler]]),
477
+ });
478
+ const { ctx } = createTestContext({
479
+ getStreamEnabled: () => false,
480
+ getClientToolHandlers: () => new Map([['my_tool', handler]]),
481
+ client: {
482
+ ...baseCtx.client,
483
+ http: {
484
+ ...baseCtx.client.http,
485
+ request: jest.fn().mockResolvedValue({ status: ChatStatusBusy }),
486
+ },
487
+ },
488
+ });
489
+ const { internalActions } = createActions(ctx);
490
+ mockAgentApi.fetchChat
491
+ .mockResolvedValueOnce({
492
+ id: 'chat-full-id-123',
493
+ status: ChatStatusBusy,
494
+ chat_messages: [],
495
+ })
496
+ .mockResolvedValueOnce({
497
+ id: 'chat-full-id-123',
498
+ status: ChatStatusBusy,
499
+ chat_messages: [toolMessage],
500
+ });
501
+ internalActions.streamChat('chat-full-id-123');
502
+ await Promise.resolve();
503
+ await pollInstances[0].options.onData?.({ status: 'idle' });
504
+ await new Promise((resolve) => setImmediate(resolve));
505
+ expect(handler).toHaveBeenCalledWith({ y: 2 });
506
+ expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-inv-poll', 'poll ok');
507
+ });
351
508
  });
352
509
  describe('client tool deduplication', () => {
353
510
  it('should not submit the same client tool invocation twice', async () => {
@@ -378,6 +535,14 @@ describe('createActions', () => {
378
535
  });
379
536
  });
380
537
  describe('publicActions lifecycle', () => {
538
+ it('uploadFile should delegate to the API layer', async () => {
539
+ const file = new File(['hello'], 'notes.txt', { type: 'text/plain' });
540
+ const { ctx } = createTestContext();
541
+ const { publicActions } = createActions(ctx);
542
+ const result = await publicActions.uploadFile(file);
543
+ expect(mockAgentApi.uploadFile).toHaveBeenCalledWith(ctx.client, file);
544
+ expect(result).toEqual(expect.objectContaining({ uri: 'inf://files/uploaded', filename: 'notes.txt' }));
545
+ });
381
546
  it('reset should stop stream and dispatch RESET', async () => {
382
547
  const { ctx, dispatch } = createTestContext();
383
548
  const { publicActions } = createActions(ctx);
@@ -422,6 +587,38 @@ describe('createActions', () => {
422
587
  payload: 'idle',
423
588
  });
424
589
  });
590
+ it('reset should allow the same client tool invocation to dispatch again', async () => {
591
+ const handler = jest.fn().mockResolvedValue('ok');
592
+ const { ctx } = createTestContext({
593
+ getClientToolHandlers: () => new Map([['my_tool', handler]]),
594
+ });
595
+ const { publicActions, internalActions } = createActions(ctx);
596
+ internalActions.streamChat('chat-full-id-123');
597
+ await Promise.resolve();
598
+ const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
599
+ const toolMessage = makeMessage({
600
+ chat_id: 'chat-short',
601
+ tool_invocations: [
602
+ {
603
+ id: 'tool-inv-reset',
604
+ type: ToolTypeClient,
605
+ status: ToolInvocationStatusAwaitingInput,
606
+ function: { name: 'my_tool', arguments: {} },
607
+ },
608
+ ],
609
+ });
610
+ onMessage(toolMessage);
611
+ await new Promise((resolve) => setImmediate(resolve));
612
+ expect(handler).toHaveBeenCalledTimes(1);
613
+ publicActions.reset();
614
+ internalActions.streamChat('chat-full-id-123');
615
+ await Promise.resolve();
616
+ const onMessageAfterReset = streamInstances[1].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
617
+ onMessageAfterReset(toolMessage);
618
+ await new Promise((resolve) => setImmediate(resolve));
619
+ expect(handler).toHaveBeenCalledTimes(2);
620
+ expect(mockAgentApi.submitToolResult).toHaveBeenCalledTimes(2);
621
+ });
425
622
  });
426
623
  describe('HIL tool actions', () => {
427
624
  it('approveTool should delegate to the API', async () => {
@@ -460,6 +657,33 @@ describe('createActions', () => {
460
657
  });
461
658
  expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'approve failed' }));
462
659
  });
660
+ it('rejectTool should set error state when API fails', async () => {
661
+ mockAgentApi.rejectTool.mockRejectedValueOnce(new Error('reject failed'));
662
+ const onError = jest.fn();
663
+ const { ctx, dispatch } = createTestContext({ callbacks: { onError } });
664
+ const { publicActions } = createActions(ctx);
665
+ await expect(publicActions.rejectTool('inv-1', 'unsafe')).rejects.toThrow('reject failed');
666
+ expect(dispatch).toHaveBeenCalledWith({
667
+ type: 'SET_CONNECTION_STATUS',
668
+ payload: 'error',
669
+ });
670
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'reject failed' }));
671
+ });
672
+ it('alwaysAllowTool should set error state when API fails', async () => {
673
+ mockAgentApi.alwaysAllowTool.mockRejectedValueOnce(new Error('allow failed'));
674
+ const onError = jest.fn();
675
+ const { ctx, dispatch } = createTestContext({
676
+ getChatId: () => 'chat-short',
677
+ callbacks: { onError },
678
+ });
679
+ const { publicActions } = createActions(ctx);
680
+ await expect(publicActions.alwaysAllowTool('inv-1', 'my_tool')).rejects.toThrow('allow failed');
681
+ expect(dispatch).toHaveBeenCalledWith({
682
+ type: 'SET_CONNECTION_STATUS',
683
+ payload: 'error',
684
+ });
685
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'allow failed' }));
686
+ });
463
687
  });
464
688
  describe('setChatId', () => {
465
689
  it('should no-op when the chat id is unchanged', async () => {
@@ -1,6 +1,6 @@
1
1
  import { HttpClient } from '../http/client';
2
2
  import { FilesAPI } from '../api/files';
3
- import { sendAdHocMessage, sendTemplateMessage, sendMessage, submitToolResult, approveTool, rejectTool, alwaysAllowTool, fetchChat, stopChat, getChatStreamConfig, } from './api';
3
+ import { sendAdHocMessage, sendTemplateMessage, sendMessage, submitToolResult, approveTool, rejectTool, alwaysAllowTool, fetchChat, stopChat, getChatStreamConfig, uploadFile, } from './api';
4
4
  import { ToolTypeClient } from '../types';
5
5
  const mockFetch = jest.fn();
6
6
  global.fetch = mockFetch;
@@ -205,4 +205,21 @@ describe('agent/api', () => {
205
205
  expect(config.headers).toEqual(expect.objectContaining({ Authorization: expect.stringContaining('Bearer') }));
206
206
  });
207
207
  });
208
+ describe('uploadFile', () => {
209
+ it('should delegate to client.files.upload and return the uploaded file ref', async () => {
210
+ const client = makeClient();
211
+ const fileRecord = {
212
+ id: 'file-1',
213
+ uri: 'inf://files/uploaded',
214
+ filename: 'notes.txt',
215
+ content_type: 'text/plain',
216
+ };
217
+ const uploadSpy = jest.spyOn(client.files, 'upload').mockResolvedValue(fileRecord);
218
+ const file = new File(['hello'], 'notes.txt', { type: 'text/plain' });
219
+ const result = await uploadFile(client, file);
220
+ expect(uploadSpy).toHaveBeenCalledWith(file);
221
+ expect(result).toEqual(fileRecord);
222
+ uploadSpy.mockRestore();
223
+ });
224
+ });
208
225
  });
@@ -1,5 +1,6 @@
1
1
  import { HttpClient } from '../http/client';
2
2
  import { StreamableManager } from '../http/streamable';
3
+ import { PollManager } from '../http/poll';
3
4
  import { ChatStatusBusy, ChatStatusIdle, ToolInvocationStatusAwaitingInput, ToolTypeClient, } from '../types';
4
5
  import { FilesAPI } from './files';
5
6
  import { AgentsAPI } from './agents';
@@ -70,6 +71,32 @@ 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
+ });
73
100
  it('should dispatch onToolCall once per client tool invocation', async () => {
74
101
  const toolInvocation = {
75
102
  id: 'tool-inv-1',
@@ -104,6 +131,30 @@ describe('Agent.sendMessage (polling mode)', () => {
104
131
  args: { x: 1 },
105
132
  });
106
133
  });
134
+ it('should call onMessage for each chat message during polling', async () => {
135
+ const msg1 = makeMessage({ id: 'msg-1', content: 'first' });
136
+ const msg2 = makeMessage({ id: 'msg-2', content: 'second' });
137
+ mockJsonResponse({
138
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
139
+ assistant_message: makeMessage(),
140
+ });
141
+ mockJsonResponse({ status: ChatStatusBusy });
142
+ mockJsonResponse({
143
+ id: 'chat-1',
144
+ status: ChatStatusBusy,
145
+ chat_messages: [msg1, msg2],
146
+ });
147
+ mockJsonResponse({ status: ChatStatusIdle });
148
+ mockJsonResponse({
149
+ id: 'chat-1',
150
+ status: ChatStatusIdle,
151
+ chat_messages: [msg1, msg2],
152
+ });
153
+ const onMessage = jest.fn();
154
+ await agent().sendMessage('hello', { stream: false, onMessage });
155
+ expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ id: 'msg-1', content: 'first' }));
156
+ expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ id: 'msg-2', content: 'second' }));
157
+ });
107
158
  it('should return chat output from run() after polling completes', async () => {
108
159
  const userMessage = makeMessage({ id: 'user-1', role: 'user' });
109
160
  const assistantMessage = makeMessage();
@@ -124,6 +175,19 @@ describe('Agent.sendMessage (polling mode)', () => {
124
175
  const output = await agent().run('compute');
125
176
  expect(output).toEqual({ answer: 42 });
126
177
  });
178
+ it('should return null from run() when the chat has no finish output', async () => {
179
+ mockJsonResponse({
180
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
181
+ assistant_message: makeMessage(),
182
+ });
183
+ mockJsonResponse({ status: ChatStatusBusy });
184
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy });
185
+ mockJsonResponse({ status: ChatStatusIdle });
186
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle });
187
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, output: null });
188
+ const output = await agent().run('no finish tool');
189
+ expect(output).toBeNull();
190
+ });
127
191
  });
128
192
  describe('Agent.sendMessage (streaming mode)', () => {
129
193
  beforeEach(() => {
@@ -189,6 +253,19 @@ describe('Agent.sendMessage (streaming mode)', () => {
189
253
  args: { x: 1 },
190
254
  });
191
255
  });
256
+ it('should return immediately without waiting when stream is true and no callbacks', async () => {
257
+ const userMessage = makeMessage({ id: 'user-1', role: 'user' });
258
+ const assistantMessage = makeMessage({ id: 'asst-1' });
259
+ mockJsonResponse({
260
+ user_message: userMessage,
261
+ assistant_message: assistantMessage,
262
+ });
263
+ const result = await streamingAgent().sendMessage('hello');
264
+ expect(result.userMessage).toEqual(userMessage);
265
+ expect(result.assistantMessage).toEqual(assistantMessage);
266
+ expect(mockFetch).toHaveBeenCalledTimes(1);
267
+ expect(mockFetch.mock.calls[0][0]).toContain('/agents/run');
268
+ });
192
269
  it('should open the stream before POST when continuing an existing chat', async () => {
193
270
  const http = new HttpClient({
194
271
  apiKey: 'test-key',
@@ -281,6 +358,79 @@ describe('Agent.sendMessage (file attachments)', () => {
281
358
  expect(body.input.files).toEqual(['inf://files/doc']);
282
359
  expect(mockFetch.mock.calls.filter(([url]) => String(url).includes('/files')).length).toBe(0);
283
360
  });
361
+ it('should upload Blob attachments before POST /agents/run', async () => {
362
+ const fileRecord = {
363
+ id: 'file-blob',
364
+ uri: 'inf://files/blob-direct',
365
+ upload_url: 'https://upload.example.com/put',
366
+ content_type: 'image/png',
367
+ };
368
+ mockJsonResponse([fileRecord]);
369
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
370
+ mockJsonResponse({
371
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
372
+ assistant_message: makeMessage(),
373
+ });
374
+ mockJsonResponse({ status: ChatStatusBusy });
375
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
376
+ mockJsonResponse({ status: ChatStatusIdle });
377
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, chat_messages: [] });
378
+ const blob = new Blob(['png-bytes'], { type: 'image/png' });
379
+ await agent().sendMessage('see image', { stream: false, files: [blob] });
380
+ const fileCreateCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/files') && !String(url).includes('upload.example.com'));
381
+ expect(fileCreateCall).toBeDefined();
382
+ const runCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/agents/run'));
383
+ const body = JSON.parse(String(runCall[1].body));
384
+ expect(body.input.images).toEqual(['inf://files/blob-direct']);
385
+ expect(body.input.files).toBeUndefined();
386
+ });
387
+ });
388
+ describe('Agent.sendMessage (template ref)', () => {
389
+ beforeEach(() => {
390
+ jest.clearAllMocks();
391
+ });
392
+ const templateAgent = (context) => {
393
+ const http = new HttpClient({
394
+ apiKey: 'test-key',
395
+ stream: false,
396
+ pollIntervalMs: 20,
397
+ });
398
+ return new AgentsAPI(http, new FilesAPI(http)).create('inference/my-agent', { context });
399
+ };
400
+ function mockRunAndPoll() {
401
+ mockJsonResponse({
402
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
403
+ assistant_message: makeMessage(),
404
+ });
405
+ mockJsonResponse({ status: ChatStatusBusy });
406
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
407
+ mockJsonResponse({ status: ChatStatusIdle });
408
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, chat_messages: [] });
409
+ }
410
+ it('should POST agent ref and context to /agents/run', async () => {
411
+ mockRunAndPoll();
412
+ await templateAgent({ tenant: 'acme' }).sendMessage('hello', { stream: false });
413
+ const runCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/agents/run'));
414
+ const body = JSON.parse(String(runCall[1].body));
415
+ expect(body.agent).toBe('inference/my-agent');
416
+ expect(body.agent_config).toBeUndefined();
417
+ expect(body.context).toEqual({ tenant: 'acme' });
418
+ expect(body.chat_id).toBeNull();
419
+ expect(body.input.text).toBe('hello');
420
+ });
421
+ it('should include chat_id on follow-up messages', async () => {
422
+ const agentInstance = templateAgent();
423
+ mockRunAndPoll();
424
+ await agentInstance.sendMessage('first', { stream: false });
425
+ jest.clearAllMocks();
426
+ mockRunAndPoll();
427
+ await agentInstance.sendMessage('second', { stream: false });
428
+ const runCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/agents/run'));
429
+ const body = JSON.parse(String(runCall[1].body));
430
+ expect(body.agent).toBe('inference/my-agent');
431
+ expect(body.chat_id).toBe('chat-1');
432
+ expect(body.input.text).toBe('second');
433
+ });
284
434
  });
285
435
  describe('Agent.sendMessage (ad-hoc config)', () => {
286
436
  beforeEach(() => {
@@ -319,6 +469,31 @@ describe('Agent.sendMessage (ad-hoc config)', () => {
319
469
  expect(body.agent_name).toBe('adhoc-bot');
320
470
  expect(body.input.text).toBe('hello');
321
471
  });
472
+ it('should prefer AgentOptions.name over config.name for agent_name', async () => {
473
+ const http = new HttpClient({
474
+ apiKey: 'test-key',
475
+ stream: false,
476
+ pollIntervalMs: 20,
477
+ });
478
+ const namedAgent = new AgentsAPI(http, new FilesAPI(http)).create({
479
+ core_app: { ref: 'openrouter/claude@latest' },
480
+ system_prompt: 'You are helpful',
481
+ name: 'config-name',
482
+ }, { name: 'override-name' });
483
+ mockJsonResponse({
484
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
485
+ assistant_message: makeMessage(),
486
+ });
487
+ mockJsonResponse({ status: ChatStatusBusy });
488
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
489
+ mockJsonResponse({ status: ChatStatusIdle });
490
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, chat_messages: [] });
491
+ await namedAgent.sendMessage('hello', { stream: false });
492
+ const runCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/agents/run'));
493
+ const body = JSON.parse(String(runCall[1].body));
494
+ expect(body.agent_name).toBe('override-name');
495
+ expect(body.agent_config.name).toBe('config-name');
496
+ });
322
497
  });
323
498
  describe('Agent lifecycle', () => {
324
499
  beforeEach(() => {
@@ -356,6 +531,28 @@ describe('Agent lifecycle', () => {
356
531
  await agentInstance.stopChat();
357
532
  expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/chats/chat-1/stop'), expect.anything());
358
533
  });
534
+ it('disconnect should stop active poll managers', async () => {
535
+ jest.useFakeTimers();
536
+ const stopSpy = jest.spyOn(PollManager.prototype, 'stop');
537
+ const agentInstance = agent();
538
+ mockJsonResponse({
539
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
540
+ assistant_message: makeMessage(),
541
+ });
542
+ mockJsonResponse({ status: ChatStatusBusy });
543
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
544
+ const sendPromise = agentInstance.sendMessage('hello', {
545
+ stream: false,
546
+ pollIntervalMs: 5000,
547
+ });
548
+ await Promise.resolve();
549
+ await jest.advanceTimersByTimeAsync(0);
550
+ agentInstance.disconnect();
551
+ expect(stopSpy).toHaveBeenCalled();
552
+ stopSpy.mockRestore();
553
+ jest.useRealTimers();
554
+ sendPromise.catch(() => undefined);
555
+ });
359
556
  it('disconnect should stop active stream managers', async () => {
360
557
  const http = new HttpClient({
361
558
  apiKey: 'test-key',
@@ -436,6 +633,64 @@ describe('Agent lifecycle', () => {
436
633
  await agentInstance.stopChat();
437
634
  expect(mockFetch).not.toHaveBeenCalled();
438
635
  });
636
+ it('reset should allow onToolCall to fire again for the same invocation id', async () => {
637
+ const toolInvocation = {
638
+ id: 'tool-inv-reset',
639
+ type: ToolTypeClient,
640
+ status: ToolInvocationStatusAwaitingInput,
641
+ function: { name: 'my_tool', arguments: { x: 1 } },
642
+ };
643
+ const messageWithTool = makeMessage({ tool_invocations: [toolInvocation] });
644
+ const agentInstance = agent();
645
+ const onMessage = jest.fn();
646
+ const onToolCall = jest.fn();
647
+ mockJsonResponse({
648
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
649
+ assistant_message: makeMessage(),
650
+ });
651
+ mockJsonResponse({ status: ChatStatusBusy });
652
+ mockJsonResponse({
653
+ id: 'chat-1',
654
+ status: ChatStatusBusy,
655
+ chat_messages: [messageWithTool],
656
+ });
657
+ mockJsonResponse({ status: ChatStatusBusy });
658
+ mockJsonResponse({ status: ChatStatusIdle });
659
+ mockJsonResponse({
660
+ id: 'chat-1',
661
+ status: ChatStatusIdle,
662
+ chat_messages: [messageWithTool],
663
+ });
664
+ await agentInstance.sendMessage('run tool', { stream: false, onMessage, onToolCall });
665
+ expect(onToolCall).toHaveBeenCalledTimes(1);
666
+ agentInstance.reset();
667
+ jest.clearAllMocks();
668
+ onToolCall.mockClear();
669
+ mockJsonResponse({
670
+ user_message: makeMessage({ id: 'user-2', role: 'user' }),
671
+ assistant_message: makeMessage({ id: 'asst-2' }),
672
+ });
673
+ mockJsonResponse({ status: ChatStatusBusy });
674
+ mockJsonResponse({
675
+ id: 'chat-1',
676
+ status: ChatStatusBusy,
677
+ chat_messages: [messageWithTool],
678
+ });
679
+ mockJsonResponse({ status: ChatStatusBusy });
680
+ mockJsonResponse({ status: ChatStatusIdle });
681
+ mockJsonResponse({
682
+ id: 'chat-1',
683
+ status: ChatStatusIdle,
684
+ chat_messages: [messageWithTool],
685
+ });
686
+ await agentInstance.sendMessage('run tool again', { stream: false, onMessage, onToolCall });
687
+ expect(onToolCall).toHaveBeenCalledTimes(1);
688
+ expect(onToolCall).toHaveBeenCalledWith({
689
+ id: 'tool-inv-reset',
690
+ name: 'my_tool',
691
+ args: { x: 1 },
692
+ });
693
+ });
439
694
  });
440
695
  describe('Agent.getChat', () => {
441
696
  beforeEach(() => {
@@ -459,11 +714,41 @@ describe('Agent.getChat', () => {
459
714
  expect(url).toContain('/chats/chat-42');
460
715
  expect(init.method).toBe('GET');
461
716
  });
717
+ it('should GET /chats/{id} with established chat id when chatId is omitted', async () => {
718
+ const agentInstance = agent();
719
+ mockJsonResponse({
720
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
721
+ assistant_message: makeMessage(),
722
+ });
723
+ mockJsonResponse({ status: ChatStatusBusy });
724
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
725
+ mockJsonResponse({ status: ChatStatusIdle });
726
+ mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, chat_messages: [] });
727
+ await agentInstance.sendMessage('hello', { stream: false });
728
+ jest.clearAllMocks();
729
+ const chat = { id: 'chat-1', status: 'idle', chat_messages: [] };
730
+ mockJsonResponse(chat);
731
+ const result = await agentInstance.getChat();
732
+ expect(result).toEqual(chat);
733
+ const [url, init] = mockFetch.mock.calls[0];
734
+ expect(url).toContain('/chats/chat-1');
735
+ expect(init.method).toBe('GET');
736
+ });
462
737
  });
463
738
  describe('Agent.submitToolResult', () => {
464
739
  beforeEach(() => {
465
740
  jest.clearAllMocks();
466
741
  });
742
+ it('should POST plain string results to /tools/{invocationId}', async () => {
743
+ const http = new HttpClient({ apiKey: 'test-key' });
744
+ const agentInstance = new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
745
+ mockJsonResponse(null);
746
+ await agentInstance.submitToolResult('inv-plain', 'done');
747
+ const [url, init] = mockFetch.mock.calls[0];
748
+ expect(url).toContain('/tools/inv-plain');
749
+ expect(init.method).toBe('POST');
750
+ expect(JSON.parse(String(init.body))).toEqual({ result: 'done' });
751
+ });
467
752
  it('should JSON-stringify structured action results', async () => {
468
753
  const http = new HttpClient({ apiKey: 'test-key' });
469
754
  const agentInstance = new AgentsAPI(http, new FilesAPI(http)).create('my-agent');