@inferencesh/sdk 0.6.14 → 0.6.19

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.
Files changed (89) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/actions.js +10 -0
  3. package/dist/agent/provider.d.ts +2 -1
  4. package/dist/agent/provider.js +5 -5
  5. package/dist/agent/types.d.ts +3 -0
  6. package/dist/api/apps.d.ts +4 -0
  7. package/dist/api/apps.js +6 -0
  8. package/dist/api/index.d.ts +3 -3
  9. package/dist/http/index.d.ts +3 -3
  10. package/dist/index.d.ts +9 -9
  11. package/dist/types.d.ts +131 -19
  12. package/dist/types.js +26 -9
  13. package/package.json +8 -5
  14. package/dist/agent/actions.test.d.ts +0 -1
  15. package/dist/agent/actions.test.js +0 -820
  16. package/dist/agent/api.test.d.ts +0 -1
  17. package/dist/agent/api.test.js +0 -246
  18. package/dist/agent/reducer.test.d.ts +0 -1
  19. package/dist/agent/reducer.test.js +0 -131
  20. package/dist/agent/types.test.d.ts +0 -1
  21. package/dist/agent/types.test.js +0 -75
  22. package/dist/api/agents.test.d.ts +0 -1
  23. package/dist/api/agents.test.js +0 -1014
  24. package/dist/api/api-keys.test.d.ts +0 -1
  25. package/dist/api/api-keys.test.js +0 -44
  26. package/dist/api/apps.test.d.ts +0 -1
  27. package/dist/api/apps.test.js +0 -135
  28. package/dist/api/chats.test.d.ts +0 -1
  29. package/dist/api/chats.test.js +0 -85
  30. package/dist/api/engines.test.d.ts +0 -1
  31. package/dist/api/engines.test.js +0 -133
  32. package/dist/api/files.test.d.ts +0 -1
  33. package/dist/api/files.test.js +0 -256
  34. package/dist/api/flow-runs.test.d.ts +0 -1
  35. package/dist/api/flow-runs.test.js +0 -97
  36. package/dist/api/flows.test.d.ts +0 -1
  37. package/dist/api/flows.test.js +0 -118
  38. package/dist/api/integrations.test.d.ts +0 -1
  39. package/dist/api/integrations.test.js +0 -109
  40. package/dist/api/knowledge.test.d.ts +0 -1
  41. package/dist/api/knowledge.test.js +0 -238
  42. package/dist/api/mcp-servers.test.d.ts +0 -1
  43. package/dist/api/mcp-servers.test.js +0 -98
  44. package/dist/api/projects.test.d.ts +0 -1
  45. package/dist/api/projects.test.js +0 -61
  46. package/dist/api/search.test.d.ts +0 -1
  47. package/dist/api/search.test.js +0 -50
  48. package/dist/api/secrets.test.d.ts +0 -1
  49. package/dist/api/secrets.test.js +0 -61
  50. package/dist/api/sessions.test.d.ts +0 -1
  51. package/dist/api/sessions.test.js +0 -61
  52. package/dist/api/tasks.test.d.ts +0 -1
  53. package/dist/api/tasks.test.js +0 -381
  54. package/dist/api/teams.test.d.ts +0 -1
  55. package/dist/api/teams.test.js +0 -139
  56. package/dist/client.test.d.ts +0 -1
  57. package/dist/client.test.js +0 -446
  58. package/dist/http/client.test.d.ts +0 -1
  59. package/dist/http/client.test.js +0 -435
  60. package/dist/http/errors.test.d.ts +0 -1
  61. package/dist/http/errors.test.js +0 -66
  62. package/dist/http/poll.test.d.ts +0 -1
  63. package/dist/http/poll.test.js +0 -146
  64. package/dist/http/streamable.test.d.ts +0 -4
  65. package/dist/http/streamable.test.js +0 -510
  66. package/dist/integration.test.d.ts +0 -9
  67. package/dist/integration.test.js +0 -107
  68. package/dist/proxy/express.test.d.ts +0 -1
  69. package/dist/proxy/express.test.js +0 -106
  70. package/dist/proxy/hono.test.d.ts +0 -1
  71. package/dist/proxy/hono.test.js +0 -90
  72. package/dist/proxy/index.test.d.ts +0 -1
  73. package/dist/proxy/index.test.js +0 -173
  74. package/dist/proxy/nextjs.test.d.ts +0 -1
  75. package/dist/proxy/nextjs.test.js +0 -125
  76. package/dist/proxy/remix.test.d.ts +0 -1
  77. package/dist/proxy/remix.test.js +0 -66
  78. package/dist/proxy/svelte.test.d.ts +0 -1
  79. package/dist/proxy/svelte.test.js +0 -69
  80. package/dist/sessions.integration.test.d.ts +0 -13
  81. package/dist/sessions.integration.test.js +0 -310
  82. package/dist/stream.test.d.ts +0 -1
  83. package/dist/stream.test.js +0 -363
  84. package/dist/streamable.integration.test.d.ts +0 -11
  85. package/dist/streamable.integration.test.js +0 -150
  86. package/dist/tool-builder.test.d.ts +0 -1
  87. package/dist/tool-builder.test.js +0 -407
  88. package/dist/utils.test.d.ts +0 -1
  89. package/dist/utils.test.js +0 -43
@@ -1,820 +0,0 @@
1
- import { ChatStatusBusy, ToolInvocationStatusAwaitingInput, ToolInvocationStatusInProgress, ToolTypeClient, } from '../types';
2
- import { createActions, getClientToolHandlers } from './actions';
3
- import * as agentApi from './api';
4
- import { PollManager } from '../http/poll';
5
- import { StreamableManager } from '../http/streamable';
6
- jest.mock('./api');
7
- jest.mock('../http/streamable');
8
- jest.mock('../http/poll');
9
- const mockAgentApi = agentApi;
10
- function makeMessage(overrides = {}) {
11
- return {
12
- id: 'msg-1',
13
- chat_id: 'chat-full-id-123',
14
- role: 'assistant',
15
- content: 'hello',
16
- ...overrides,
17
- };
18
- }
19
- function createTestContext(overrides = {}) {
20
- const dispatch = jest.fn();
21
- let streamManager;
22
- const getStreamManager = () => streamManager;
23
- const setStreamManager = jest.fn((manager) => {
24
- streamManager = manager;
25
- });
26
- const adHocConfig = {
27
- core_app: { ref: 'openrouter/claude@abc' },
28
- system_prompt: 'test',
29
- };
30
- const ctx = {
31
- client: {
32
- http: {
33
- request: jest.fn(),
34
- getStreamableConfig: jest.fn(() => ({ url: 'https://stream.test', headers: {} })),
35
- getStreamDefault: jest.fn(() => true),
36
- getPollIntervalMs: jest.fn(() => 50),
37
- },
38
- files: { upload: jest.fn() },
39
- },
40
- dispatch,
41
- getConfig: () => adHocConfig,
42
- getChatId: () => 'chat-short',
43
- getClientToolHandlers: () => new Map(),
44
- getStreamManager,
45
- setStreamManager,
46
- getStreamEnabled: () => true,
47
- getPollIntervalMs: () => 50,
48
- callbacks: {},
49
- ...overrides,
50
- };
51
- return { ctx, dispatch, setStreamManager };
52
- }
53
- describe('createActions', () => {
54
- let pollInstances;
55
- let streamInstances;
56
- beforeEach(() => {
57
- jest.clearAllMocks();
58
- pollInstances = [];
59
- streamInstances = [];
60
- PollManager.mockImplementation((options) => {
61
- const instance = {
62
- options,
63
- start: jest.fn(),
64
- stop: jest.fn(),
65
- };
66
- pollInstances.push(instance);
67
- return instance;
68
- });
69
- StreamableManager.mockImplementation((options) => {
70
- const instance = {
71
- options,
72
- addEventListener: jest.fn(),
73
- start: jest.fn(),
74
- stop: jest.fn(),
75
- };
76
- streamInstances.push(instance);
77
- return instance;
78
- });
79
- mockAgentApi.fetchChat.mockResolvedValue({
80
- id: 'chat-full-id-123',
81
- status: ChatStatusBusy,
82
- chat_messages: [],
83
- });
84
- mockAgentApi.getChatStreamConfig.mockReturnValue({
85
- url: 'https://api.test/chats/chat-full-id-123/stream',
86
- headers: {},
87
- });
88
- mockAgentApi.sendMessage.mockResolvedValue({
89
- chatId: 'chat-full-id-123',
90
- userMessage: makeMessage({ id: 'u1', role: 'user' }),
91
- assistantMessage: makeMessage(),
92
- });
93
- mockAgentApi.submitToolResult.mockResolvedValue(undefined);
94
- mockAgentApi.approveTool.mockResolvedValue(undefined);
95
- mockAgentApi.rejectTool.mockResolvedValue(undefined);
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
- });
103
- });
104
- describe('updateMessage (via stream listeners)', () => {
105
- it('should ignore messages for a different chat when IDs do not prefix-match', async () => {
106
- const { ctx, dispatch } = createTestContext({ getChatId: () => 'other-chat' });
107
- const { internalActions } = createActions(ctx);
108
- internalActions.streamChat('chat-full-id-123');
109
- await Promise.resolve();
110
- const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
111
- onMessage(makeMessage({ chat_id: 'unrelated-chat-id' }));
112
- expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'UPDATE_MESSAGE' }));
113
- });
114
- it('should accept messages when chat_id is a prefix extension of the short chatId', async () => {
115
- const { ctx, dispatch } = createTestContext({ getChatId: () => 'chat-short' });
116
- const { internalActions } = createActions(ctx);
117
- internalActions.streamChat('chat-short');
118
- await Promise.resolve();
119
- const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
120
- onMessage(makeMessage({ chat_id: 'chat-short-full-suffix' }));
121
- expect(dispatch).toHaveBeenCalledWith({
122
- type: 'UPDATE_MESSAGE',
123
- payload: expect.objectContaining({ chat_id: 'chat-short-full-suffix' }),
124
- });
125
- });
126
- it('should run the handler and submit its result when a client tool is available', async () => {
127
- const handler = jest.fn().mockResolvedValue('tool ok');
128
- const { ctx } = createTestContext({
129
- getClientToolHandlers: () => new Map([['my_tool', handler]]),
130
- });
131
- const { internalActions } = createActions(ctx);
132
- internalActions.streamChat('chat-full-id-123');
133
- await Promise.resolve();
134
- const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
135
- onMessage(makeMessage({
136
- chat_id: 'chat-short',
137
- tool_invocations: [
138
- {
139
- id: 'tool-inv-ok',
140
- type: ToolTypeClient,
141
- status: ToolInvocationStatusAwaitingInput,
142
- function: { name: 'my_tool', arguments: { x: 1 } },
143
- },
144
- ],
145
- }));
146
- await Promise.resolve();
147
- expect(handler).toHaveBeenCalledWith({ x: 1 });
148
- expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-inv-ok', 'tool ok');
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
- });
174
- it('should submit a JSON error when a client tool handler throws', async () => {
175
- const handler = jest.fn().mockRejectedValue(new Error('handler boom'));
176
- const { ctx } = createTestContext({
177
- getClientToolHandlers: () => new Map([['my_tool', handler]]),
178
- });
179
- const { internalActions } = createActions(ctx);
180
- internalActions.streamChat('chat-full-id-123');
181
- await Promise.resolve();
182
- const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
183
- onMessage(makeMessage({
184
- chat_id: 'chat-short',
185
- tool_invocations: [
186
- {
187
- id: 'tool-inv-err',
188
- type: ToolTypeClient,
189
- status: ToolInvocationStatusAwaitingInput,
190
- function: { name: 'my_tool', arguments: {} },
191
- },
192
- ],
193
- }));
194
- await new Promise((resolve) => setImmediate(resolve));
195
- expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-inv-err', expect.stringContaining('handler boom'));
196
- });
197
- it('should submit not_available when a client tool has no handler', async () => {
198
- const { ctx } = createTestContext({
199
- getClientToolHandlers: () => new Map([['other_tool', jest.fn().mockResolvedValue('ok')]]),
200
- });
201
- const { internalActions } = createActions(ctx);
202
- internalActions.streamChat('chat-full-id-123');
203
- await Promise.resolve();
204
- const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
205
- onMessage(makeMessage({
206
- chat_id: 'chat-short',
207
- tool_invocations: [
208
- {
209
- id: 'tool-missing-handler',
210
- type: ToolTypeClient,
211
- status: ToolInvocationStatusAwaitingInput,
212
- function: { name: 'missing_tool', arguments: {} },
213
- },
214
- ],
215
- }));
216
- await Promise.resolve();
217
- expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-missing-handler', expect.stringContaining('not_available'));
218
- });
219
- });
220
- describe('streamChat', () => {
221
- it('should use PollManager when streaming is disabled', async () => {
222
- const { ctx } = createTestContext({
223
- getStreamEnabled: () => false,
224
- });
225
- const { internalActions } = createActions(ctx);
226
- internalActions.streamChat('chat-full-id-123');
227
- await Promise.resolve();
228
- expect(PollManager).toHaveBeenCalled();
229
- expect(StreamableManager).not.toHaveBeenCalled();
230
- expect(pollInstances[0].options.pollFunction).toBeDefined();
231
- expect(pollInstances[0].start).toHaveBeenCalled();
232
- });
233
- it('should stop an existing stream manager before starting a new connection', async () => {
234
- const { ctx, dispatch } = createTestContext();
235
- const { internalActions } = createActions(ctx);
236
- internalActions.streamChat('chat-full-id-123');
237
- await Promise.resolve();
238
- const firstManager = streamInstances[0];
239
- internalActions.streamChat('chat-full-id-123');
240
- await Promise.resolve();
241
- expect(firstManager.stop).toHaveBeenCalled();
242
- expect(streamInstances).toHaveLength(2);
243
- expect(streamInstances[1].start).toHaveBeenCalled();
244
- expect(dispatch).toHaveBeenCalledWith({
245
- type: 'SET_CONNECTION_STATUS',
246
- payload: 'connecting',
247
- });
248
- });
249
- it('should dispatch streaming status when the stream manager starts', async () => {
250
- const onStatusChange = jest.fn();
251
- const { ctx, dispatch } = createTestContext({ callbacks: { onStatusChange } });
252
- const { internalActions } = createActions(ctx);
253
- internalActions.streamChat('chat-full-id-123');
254
- await Promise.resolve();
255
- streamInstances[0].options.onStart?.();
256
- expect(dispatch).toHaveBeenCalledWith({
257
- type: 'SET_CONNECTION_STATUS',
258
- payload: 'streaming',
259
- });
260
- expect(onStatusChange).toHaveBeenCalledWith('streaming');
261
- });
262
- });
263
- describe('stopStream', () => {
264
- it('should clear the manager ref before stop so onEnd does not double-dispatch idle', async () => {
265
- const { ctx, dispatch, setStreamManager } = createTestContext();
266
- const { internalActions } = createActions(ctx);
267
- internalActions.streamChat('chat-full-id-123');
268
- await Promise.resolve();
269
- const manager = streamInstances[0];
270
- internalActions.stopStream();
271
- expect(setStreamManager).toHaveBeenCalledWith(undefined);
272
- expect(manager.stop).toHaveBeenCalled();
273
- manager.options.onEnd?.();
274
- const idleDispatches = dispatch.mock.calls.filter(([action]) => action.type === 'SET_CONNECTION_STATUS' && action.payload === 'idle');
275
- // Only the explicit stopStream dispatch, not a second from onEnd
276
- expect(idleDispatches).toHaveLength(1);
277
- });
278
- it('should reset to idle when the stream ends unexpectedly with manager still set', async () => {
279
- const onStatusChange = jest.fn();
280
- const { ctx, dispatch } = createTestContext({ callbacks: { onStatusChange } });
281
- const { internalActions } = createActions(ctx);
282
- internalActions.streamChat('chat-full-id-123');
283
- await Promise.resolve();
284
- streamInstances[0].options.onEnd?.();
285
- expect(dispatch).toHaveBeenCalledWith({
286
- type: 'SET_CONNECTION_STATUS',
287
- payload: 'idle',
288
- });
289
- expect(onStatusChange).toHaveBeenCalledWith('idle');
290
- });
291
- it('should clear the manager ref before poll stop so onStop does not double-dispatch idle', async () => {
292
- const onStatusChange = jest.fn();
293
- const { ctx, dispatch, setStreamManager } = createTestContext({
294
- getStreamEnabled: () => false,
295
- callbacks: { onStatusChange },
296
- });
297
- const { internalActions } = createActions(ctx);
298
- internalActions.streamChat('chat-full-id-123');
299
- await Promise.resolve();
300
- const manager = pollInstances[0];
301
- internalActions.stopStream();
302
- expect(setStreamManager).toHaveBeenCalledWith(undefined);
303
- expect(manager.stop).toHaveBeenCalled();
304
- manager.options.onStop?.();
305
- const idleDispatches = dispatch.mock.calls.filter(([action]) => action.type === 'SET_CONNECTION_STATUS' && action.payload === 'idle');
306
- expect(idleDispatches).toHaveLength(1);
307
- expect(onStatusChange).toHaveBeenCalledWith('idle');
308
- });
309
- it('should reset to idle when poll transport stops unexpectedly with manager still set', async () => {
310
- const onStatusChange = jest.fn();
311
- const { ctx, dispatch } = createTestContext({
312
- getStreamEnabled: () => false,
313
- callbacks: { onStatusChange },
314
- });
315
- const { internalActions } = createActions(ctx);
316
- internalActions.streamChat('chat-full-id-123');
317
- await Promise.resolve();
318
- pollInstances[0].options.onStop?.();
319
- expect(dispatch).toHaveBeenCalledWith({
320
- type: 'SET_CONNECTION_STATUS',
321
- payload: 'idle',
322
- });
323
- expect(onStatusChange).toHaveBeenCalledWith('idle');
324
- });
325
- });
326
- describe('stream and poll error callbacks', () => {
327
- it('should forward stream errors to onError callback', async () => {
328
- const onError = jest.fn();
329
- const { ctx } = createTestContext({ callbacks: { onError } });
330
- const { internalActions } = createActions(ctx);
331
- internalActions.streamChat('chat-full-id-123');
332
- await Promise.resolve();
333
- const streamError = new Error('stream connection lost');
334
- streamInstances[0].options.onError?.(streamError);
335
- expect(onError).toHaveBeenCalledWith(streamError);
336
- });
337
- it('should forward poll manager errors to onError callback', async () => {
338
- const onError = jest.fn();
339
- const { ctx } = createTestContext({
340
- getStreamEnabled: () => false,
341
- callbacks: { onError },
342
- });
343
- const { internalActions } = createActions(ctx);
344
- internalActions.streamChat('chat-full-id-123');
345
- await Promise.resolve();
346
- const pollError = new Error('poll transport failed');
347
- pollInstances[0].options.onError?.(pollError);
348
- expect(onError).toHaveBeenCalledWith(pollError);
349
- });
350
- });
351
- describe('publicActions.sendMessage', () => {
352
- it('should call onChatCreated and start streaming for a new chat', async () => {
353
- const onChatCreated = jest.fn();
354
- const { ctx } = createTestContext({
355
- getChatId: () => null,
356
- callbacks: { onChatCreated },
357
- });
358
- const { publicActions } = createActions(ctx);
359
- await publicActions.sendMessage('hello');
360
- expect(onChatCreated).toHaveBeenCalledWith('chat-full-id-123');
361
- expect(StreamableManager).toHaveBeenCalled();
362
- });
363
- it('should reset connection status when the API returns no result', async () => {
364
- mockAgentApi.sendMessage.mockResolvedValueOnce(null);
365
- const onStatusChange = jest.fn();
366
- const { ctx, dispatch } = createTestContext({
367
- callbacks: { onStatusChange },
368
- });
369
- const { publicActions } = createActions(ctx);
370
- await publicActions.sendMessage('hello');
371
- expect(dispatch).toHaveBeenCalledWith({
372
- type: 'SET_CONNECTION_STATUS',
373
- payload: 'idle',
374
- });
375
- expect(onStatusChange).toHaveBeenCalledWith('idle');
376
- });
377
- it('should dispatch error state when sendMessage throws', async () => {
378
- mockAgentApi.sendMessage.mockRejectedValueOnce(new Error('send failed'));
379
- const onError = jest.fn();
380
- const { ctx, dispatch } = createTestContext({ callbacks: { onError } });
381
- const { publicActions } = createActions(ctx);
382
- await publicActions.sendMessage('hello');
383
- expect(dispatch).toHaveBeenCalledWith({
384
- type: 'SET_CONNECTION_STATUS',
385
- payload: 'error',
386
- });
387
- expect(dispatch).toHaveBeenCalledWith({
388
- type: 'SET_ERROR',
389
- payload: 'send failed',
390
- });
391
- expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'send failed' }));
392
- });
393
- it('should ignore whitespace-only messages', async () => {
394
- const { ctx } = createTestContext();
395
- const { publicActions } = createActions(ctx);
396
- await publicActions.sendMessage(' ');
397
- expect(mockAgentApi.sendMessage).not.toHaveBeenCalled();
398
- });
399
- it('should no-op when agent config is missing', async () => {
400
- const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { });
401
- const { ctx, dispatch } = createTestContext({ getConfig: () => null });
402
- const { publicActions } = createActions(ctx);
403
- await publicActions.sendMessage('hello');
404
- expect(mockAgentApi.sendMessage).not.toHaveBeenCalled();
405
- expect(dispatch).not.toHaveBeenCalledWith({
406
- type: 'SET_CONNECTION_STATUS',
407
- payload: 'streaming',
408
- });
409
- expect(consoleError).toHaveBeenCalledWith('[AgentSDK] No agent config provided');
410
- consoleError.mockRestore();
411
- });
412
- });
413
- describe('streamChat error handling', () => {
414
- it('should reset to idle when initial fetchChat fails', async () => {
415
- mockAgentApi.fetchChat.mockRejectedValueOnce(new Error('fetch failed'));
416
- const onStatusChange = jest.fn();
417
- const { ctx, dispatch } = createTestContext({ callbacks: { onStatusChange } });
418
- const { internalActions } = createActions(ctx);
419
- await internalActions.streamChat('chat-full-id-123');
420
- expect(dispatch).toHaveBeenCalledWith({
421
- type: 'SET_CONNECTION_STATUS',
422
- payload: 'idle',
423
- });
424
- expect(onStatusChange).toHaveBeenCalledWith('idle');
425
- expect(StreamableManager).not.toHaveBeenCalled();
426
- });
427
- it('should dispatch UPDATE_CHAT when chats stream events arrive', async () => {
428
- const onStatusChange = jest.fn();
429
- const { ctx, dispatch } = createTestContext({ callbacks: { onStatusChange } });
430
- const { internalActions } = createActions(ctx);
431
- internalActions.streamChat('chat-full-id-123');
432
- await Promise.resolve();
433
- const onChat = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chats')?.[1];
434
- onChat({ id: 'chat-full-id-123', status: ChatStatusBusy });
435
- expect(dispatch).toHaveBeenCalledWith({
436
- type: 'UPDATE_CHAT',
437
- payload: expect.objectContaining({ status: ChatStatusBusy }),
438
- });
439
- expect(onStatusChange).toHaveBeenCalledWith('streaming');
440
- });
441
- });
442
- describe('pollChat', () => {
443
- it('should dispatch streaming status when the poll manager starts', async () => {
444
- const onStatusChange = jest.fn();
445
- const { ctx, dispatch } = createTestContext({
446
- getStreamEnabled: () => false,
447
- callbacks: { onStatusChange },
448
- });
449
- const { internalActions } = createActions(ctx);
450
- internalActions.streamChat('chat-full-id-123');
451
- await Promise.resolve();
452
- pollInstances[0].options.onStart?.();
453
- expect(dispatch).toHaveBeenCalledWith({
454
- type: 'SET_CONNECTION_STATUS',
455
- payload: 'streaming',
456
- });
457
- expect(onStatusChange).toHaveBeenCalledWith('streaming');
458
- });
459
- it('should fetch full chat when poll status changes', async () => {
460
- const { ctx: baseCtx } = createTestContext({ getStreamEnabled: () => false });
461
- const { ctx } = createTestContext({
462
- getStreamEnabled: () => false,
463
- client: {
464
- ...baseCtx.client,
465
- http: { ...baseCtx.client.http, request: jest.fn().mockResolvedValue({ status: ChatStatusBusy }) },
466
- },
467
- });
468
- const { internalActions } = createActions(ctx);
469
- mockAgentApi.fetchChat
470
- .mockResolvedValueOnce({
471
- id: 'chat-full-id-123',
472
- status: ChatStatusBusy,
473
- chat_messages: [],
474
- })
475
- .mockResolvedValueOnce({
476
- id: 'chat-full-id-123',
477
- status: ChatStatusBusy,
478
- chat_messages: [makeMessage()],
479
- });
480
- internalActions.streamChat('chat-full-id-123');
481
- await Promise.resolve();
482
- await pollInstances[0].options.onData?.({ status: 'idle' });
483
- await Promise.resolve();
484
- expect(mockAgentApi.fetchChat).toHaveBeenCalledTimes(2);
485
- });
486
- it('should call onError when poll fetch fails', async () => {
487
- const onError = jest.fn();
488
- const { ctx: baseCtx } = createTestContext({ getStreamEnabled: () => false });
489
- const { ctx } = createTestContext({
490
- getStreamEnabled: () => false,
491
- callbacks: { onError },
492
- client: {
493
- ...baseCtx.client,
494
- http: { ...baseCtx.client.http, request: jest.fn().mockResolvedValue({ status: ChatStatusBusy }) },
495
- },
496
- });
497
- const { internalActions } = createActions(ctx);
498
- mockAgentApi.fetchChat
499
- .mockResolvedValueOnce({
500
- id: 'chat-full-id-123',
501
- status: ChatStatusBusy,
502
- chat_messages: [],
503
- })
504
- .mockRejectedValueOnce(new Error('poll fetch failed'));
505
- internalActions.streamChat('chat-full-id-123');
506
- await Promise.resolve();
507
- await pollInstances[0].options.onData?.({ status: 'idle' });
508
- await Promise.resolve();
509
- expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'poll fetch failed' }));
510
- });
511
- it('should not refetch chat when poll status is unchanged', async () => {
512
- const { ctx: baseCtx } = createTestContext({ getStreamEnabled: () => false });
513
- const { ctx } = createTestContext({
514
- getStreamEnabled: () => false,
515
- client: {
516
- ...baseCtx.client,
517
- http: { ...baseCtx.client.http, request: jest.fn().mockResolvedValue({ status: ChatStatusBusy }) },
518
- },
519
- });
520
- const { internalActions } = createActions(ctx);
521
- mockAgentApi.fetchChat.mockResolvedValue({
522
- id: 'chat-full-id-123',
523
- status: ChatStatusBusy,
524
- chat_messages: [],
525
- });
526
- internalActions.streamChat('chat-full-id-123');
527
- await Promise.resolve();
528
- await pollInstances[0].options.onData?.({ status: ChatStatusBusy });
529
- await Promise.resolve();
530
- const fetchCountAfterFirst = mockAgentApi.fetchChat.mock.calls.length;
531
- await pollInstances[0].options.onData?.({ status: ChatStatusBusy });
532
- await Promise.resolve();
533
- expect(mockAgentApi.fetchChat).toHaveBeenCalledTimes(fetchCountAfterFirst);
534
- });
535
- it('should dispatch client tools from poll path when status changes', async () => {
536
- const handler = jest.fn().mockResolvedValue('poll ok');
537
- const toolMessage = makeMessage({
538
- chat_id: 'chat-short',
539
- tool_invocations: [
540
- {
541
- id: 'tool-inv-poll',
542
- type: ToolTypeClient,
543
- status: ToolInvocationStatusInProgress,
544
- function: { name: 'my_tool', arguments: { y: 2 } },
545
- },
546
- ],
547
- });
548
- const { ctx: baseCtx } = createTestContext({
549
- getStreamEnabled: () => false,
550
- getClientToolHandlers: () => new Map([['my_tool', handler]]),
551
- });
552
- const { ctx } = createTestContext({
553
- getStreamEnabled: () => false,
554
- getClientToolHandlers: () => new Map([['my_tool', handler]]),
555
- client: {
556
- ...baseCtx.client,
557
- http: {
558
- ...baseCtx.client.http,
559
- request: jest.fn().mockResolvedValue({ status: ChatStatusBusy }),
560
- },
561
- },
562
- });
563
- const { internalActions } = createActions(ctx);
564
- mockAgentApi.fetchChat
565
- .mockResolvedValueOnce({
566
- id: 'chat-full-id-123',
567
- status: ChatStatusBusy,
568
- chat_messages: [],
569
- })
570
- .mockResolvedValueOnce({
571
- id: 'chat-full-id-123',
572
- status: ChatStatusBusy,
573
- chat_messages: [toolMessage],
574
- });
575
- internalActions.streamChat('chat-full-id-123');
576
- await Promise.resolve();
577
- await pollInstances[0].options.onData?.({ status: 'idle' });
578
- await new Promise((resolve) => setImmediate(resolve));
579
- expect(handler).toHaveBeenCalledWith({ y: 2 });
580
- expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-inv-poll', 'poll ok');
581
- });
582
- });
583
- describe('client tool deduplication', () => {
584
- it('should not submit the same client tool invocation twice', async () => {
585
- const handler = jest.fn().mockResolvedValue('ok');
586
- const { ctx } = createTestContext({
587
- getClientToolHandlers: () => new Map([['my_tool', handler]]),
588
- });
589
- const { internalActions } = createActions(ctx);
590
- internalActions.streamChat('chat-full-id-123');
591
- await Promise.resolve();
592
- const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
593
- const toolMessage = makeMessage({
594
- chat_id: 'chat-short',
595
- tool_invocations: [
596
- {
597
- id: 'tool-inv-dup',
598
- type: ToolTypeClient,
599
- status: ToolInvocationStatusAwaitingInput,
600
- function: { name: 'my_tool', arguments: {} },
601
- },
602
- ],
603
- });
604
- onMessage(toolMessage);
605
- onMessage(toolMessage);
606
- await new Promise((resolve) => setImmediate(resolve));
607
- expect(handler).toHaveBeenCalledTimes(1);
608
- expect(mockAgentApi.submitToolResult).toHaveBeenCalledTimes(1);
609
- });
610
- });
611
- describe('publicActions lifecycle', () => {
612
- it('uploadFile should delegate to the API layer', async () => {
613
- const file = new File(['hello'], 'notes.txt', { type: 'text/plain' });
614
- const { ctx } = createTestContext();
615
- const { publicActions } = createActions(ctx);
616
- const result = await publicActions.uploadFile(file);
617
- expect(mockAgentApi.uploadFile).toHaveBeenCalledWith(ctx.client, file);
618
- expect(result).toEqual(expect.objectContaining({ uri: 'inf://files/uploaded', filename: 'notes.txt' }));
619
- });
620
- it('reset should stop stream and dispatch RESET', async () => {
621
- const { ctx, dispatch } = createTestContext();
622
- const { publicActions } = createActions(ctx);
623
- await publicActions.sendMessage('hello');
624
- publicActions.reset();
625
- expect(dispatch).toHaveBeenCalledWith({ type: 'RESET' });
626
- });
627
- it('stopGeneration should call stopChat when chatId exists', async () => {
628
- const { ctx } = createTestContext({ getChatId: () => 'chat-short' });
629
- const { publicActions } = createActions(ctx);
630
- publicActions.stopGeneration();
631
- expect(mockAgentApi.stopChat).toHaveBeenCalledWith(ctx.client, 'chat-short');
632
- });
633
- it('stopGeneration should no-op when there is no chatId', () => {
634
- const { ctx } = createTestContext({ getChatId: () => null });
635
- const { publicActions } = createActions(ctx);
636
- publicActions.stopGeneration();
637
- expect(mockAgentApi.stopChat).not.toHaveBeenCalled();
638
- });
639
- it('submitToolResult should set error state when API fails', async () => {
640
- mockAgentApi.submitToolResult.mockRejectedValueOnce(new Error('submit failed'));
641
- const onError = jest.fn();
642
- const { ctx, dispatch } = createTestContext({ callbacks: { onError } });
643
- const { publicActions } = createActions(ctx);
644
- await expect(publicActions.submitToolResult('inv-1', 'result')).rejects.toThrow('submit failed');
645
- expect(dispatch).toHaveBeenCalledWith({
646
- type: 'SET_CONNECTION_STATUS',
647
- payload: 'error',
648
- });
649
- expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'submit failed' }));
650
- });
651
- it('clearError should reset error and connection status to idle', () => {
652
- const { ctx, dispatch } = createTestContext();
653
- const { publicActions } = createActions(ctx);
654
- publicActions.clearError();
655
- expect(dispatch).toHaveBeenCalledWith({
656
- type: 'SET_ERROR',
657
- payload: undefined,
658
- });
659
- expect(dispatch).toHaveBeenCalledWith({
660
- type: 'SET_CONNECTION_STATUS',
661
- payload: 'idle',
662
- });
663
- });
664
- it('reset should allow the same client tool invocation to dispatch again', async () => {
665
- const handler = jest.fn().mockResolvedValue('ok');
666
- const { ctx } = createTestContext({
667
- getClientToolHandlers: () => new Map([['my_tool', handler]]),
668
- });
669
- const { publicActions, internalActions } = createActions(ctx);
670
- internalActions.streamChat('chat-full-id-123');
671
- await Promise.resolve();
672
- const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
673
- const toolMessage = makeMessage({
674
- chat_id: 'chat-short',
675
- tool_invocations: [
676
- {
677
- id: 'tool-inv-reset',
678
- type: ToolTypeClient,
679
- status: ToolInvocationStatusAwaitingInput,
680
- function: { name: 'my_tool', arguments: {} },
681
- },
682
- ],
683
- });
684
- onMessage(toolMessage);
685
- await new Promise((resolve) => setImmediate(resolve));
686
- expect(handler).toHaveBeenCalledTimes(1);
687
- publicActions.reset();
688
- internalActions.streamChat('chat-full-id-123');
689
- await Promise.resolve();
690
- const onMessageAfterReset = streamInstances[1].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
691
- onMessageAfterReset(toolMessage);
692
- await new Promise((resolve) => setImmediate(resolve));
693
- expect(handler).toHaveBeenCalledTimes(2);
694
- expect(mockAgentApi.submitToolResult).toHaveBeenCalledTimes(2);
695
- });
696
- });
697
- describe('HIL tool actions', () => {
698
- it('approveTool should delegate to the API', async () => {
699
- const { ctx } = createTestContext();
700
- const { publicActions } = createActions(ctx);
701
- await publicActions.approveTool('inv-approve');
702
- expect(mockAgentApi.approveTool).toHaveBeenCalledWith(ctx.client, 'inv-approve');
703
- });
704
- it('rejectTool should pass an optional reason', async () => {
705
- const { ctx } = createTestContext();
706
- const { publicActions } = createActions(ctx);
707
- await publicActions.rejectTool('inv-reject', 'unsafe');
708
- expect(mockAgentApi.rejectTool).toHaveBeenCalledWith(ctx.client, 'inv-reject', 'unsafe');
709
- });
710
- it('alwaysAllowTool should no-op without a chatId', async () => {
711
- const { ctx } = createTestContext({ getChatId: () => null });
712
- const { publicActions } = createActions(ctx);
713
- await publicActions.alwaysAllowTool('inv-allow', 'my_tool');
714
- expect(mockAgentApi.alwaysAllowTool).not.toHaveBeenCalled();
715
- });
716
- it('alwaysAllowTool should call API when chatId exists', async () => {
717
- const { ctx } = createTestContext({ getChatId: () => 'chat-short' });
718
- const { publicActions } = createActions(ctx);
719
- await publicActions.alwaysAllowTool('inv-allow', 'my_tool');
720
- expect(mockAgentApi.alwaysAllowTool).toHaveBeenCalledWith(ctx.client, 'chat-short', 'inv-allow', 'my_tool');
721
- });
722
- it('approveTool should set error state when API fails', async () => {
723
- mockAgentApi.approveTool.mockRejectedValueOnce(new Error('approve failed'));
724
- const onError = jest.fn();
725
- const { ctx, dispatch } = createTestContext({ callbacks: { onError } });
726
- const { publicActions } = createActions(ctx);
727
- await expect(publicActions.approveTool('inv-1')).rejects.toThrow('approve failed');
728
- expect(dispatch).toHaveBeenCalledWith({
729
- type: 'SET_CONNECTION_STATUS',
730
- payload: 'error',
731
- });
732
- expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'approve failed' }));
733
- });
734
- it('rejectTool should set error state when API fails', async () => {
735
- mockAgentApi.rejectTool.mockRejectedValueOnce(new Error('reject failed'));
736
- const onError = jest.fn();
737
- const { ctx, dispatch } = createTestContext({ callbacks: { onError } });
738
- const { publicActions } = createActions(ctx);
739
- await expect(publicActions.rejectTool('inv-1', 'unsafe')).rejects.toThrow('reject failed');
740
- expect(dispatch).toHaveBeenCalledWith({
741
- type: 'SET_CONNECTION_STATUS',
742
- payload: 'error',
743
- });
744
- expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'reject failed' }));
745
- });
746
- it('alwaysAllowTool should set error state when API fails', async () => {
747
- mockAgentApi.alwaysAllowTool.mockRejectedValueOnce(new Error('allow failed'));
748
- const onError = jest.fn();
749
- const { ctx, dispatch } = createTestContext({
750
- getChatId: () => 'chat-short',
751
- callbacks: { onError },
752
- });
753
- const { publicActions } = createActions(ctx);
754
- await expect(publicActions.alwaysAllowTool('inv-1', 'my_tool')).rejects.toThrow('allow failed');
755
- expect(dispatch).toHaveBeenCalledWith({
756
- type: 'SET_CONNECTION_STATUS',
757
- payload: 'error',
758
- });
759
- expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'allow failed' }));
760
- });
761
- });
762
- describe('setChatId', () => {
763
- it('should no-op when the chat id is unchanged', async () => {
764
- const { ctx } = createTestContext({ getChatId: () => 'chat-short' });
765
- const { internalActions } = createActions(ctx);
766
- internalActions.setChatId('chat-short');
767
- await Promise.resolve();
768
- expect(StreamableManager).not.toHaveBeenCalled();
769
- });
770
- it('should reset and stop stream when chat id is cleared', async () => {
771
- const { ctx, dispatch } = createTestContext();
772
- const { internalActions } = createActions(ctx);
773
- internalActions.streamChat('chat-full-id-123');
774
- await Promise.resolve();
775
- internalActions.setChatId(null);
776
- expect(streamInstances[0].stop).toHaveBeenCalled();
777
- expect(dispatch).toHaveBeenCalledWith({ type: 'RESET' });
778
- });
779
- it('should start streaming when switching to a new chat id', async () => {
780
- const { ctx, dispatch } = createTestContext({ getChatId: () => null });
781
- const { internalActions } = createActions(ctx);
782
- internalActions.setChatId('chat-new');
783
- await Promise.resolve();
784
- expect(dispatch).toHaveBeenCalledWith({
785
- type: 'SET_CHAT_ID',
786
- payload: 'chat-new',
787
- });
788
- expect(StreamableManager).toHaveBeenCalled();
789
- });
790
- });
791
- });
792
- describe('getClientToolHandlers', () => {
793
- it('returns an empty map when config is null', () => {
794
- expect(getClientToolHandlers(null)).toEqual(new Map());
795
- });
796
- it('returns an empty map for template agent refs', () => {
797
- expect(getClientToolHandlers({ agent: 'inference/my-agent' })).toEqual(new Map());
798
- });
799
- it('returns an empty map when ad-hoc config has no tools', () => {
800
- expect(getClientToolHandlers({
801
- core_app: { ref: 'openrouter/claude@abc' },
802
- system_prompt: 'test',
803
- })).toEqual(new Map());
804
- });
805
- it('extracts client tool handlers from ad-hoc config tools', () => {
806
- const handler = jest.fn();
807
- const map = getClientToolHandlers({
808
- core_app: { ref: 'openrouter/claude@abc' },
809
- system_prompt: 'test',
810
- tools: [
811
- {
812
- schema: { name: 'browser', type: ToolTypeClient, description: 'browse' },
813
- handler,
814
- },
815
- ],
816
- });
817
- expect(map.size).toBe(1);
818
- expect(map.get('browser')).toBe(handler);
819
- });
820
- });