@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.
- package/dist/agent/actions.test.js +225 -1
- package/dist/agent/api.test.js +18 -1
- package/dist/api/agents.test.js +285 -0
- package/dist/api/knowledge.test.js +144 -0
- package/dist/api/mcp-servers.test.js +34 -0
- package/dist/api/projects.test.js +9 -0
- package/dist/api/sessions.test.js +12 -0
- package/dist/api/tasks.test.js +37 -0
- package/dist/api/teams.test.js +60 -0
- package/dist/client.test.js +112 -1
- package/dist/proxy/hono.test.d.ts +1 -0
- package/dist/proxy/hono.test.js +90 -0
- package/dist/proxy/nextjs.test.d.ts +1 -0
- package/dist/proxy/nextjs.test.js +125 -0
- package/dist/proxy/remix.test.d.ts +1 -0
- package/dist/proxy/remix.test.js +66 -0
- package/dist/proxy/svelte.test.d.ts +1 -0
- package/dist/proxy/svelte.test.js +69 -0
- package/package.json +3 -3
|
@@ -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 () => {
|
package/dist/agent/api.test.js
CHANGED
|
@@ -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
|
});
|
package/dist/api/agents.test.js
CHANGED
|
@@ -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');
|