@inferencesh/sdk 0.6.7 → 0.6.10
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/CHANGELOG.md +32 -1
- package/README.md +305 -36
- package/dist/agent/actions.test.d.ts +1 -0
- package/dist/agent/actions.test.js +487 -0
- package/dist/agent/api.test.d.ts +1 -0
- package/dist/agent/api.test.js +208 -0
- package/dist/agent/reducer.test.js +4 -0
- package/dist/agent/types.test.d.ts +1 -0
- package/dist/agent/types.test.js +75 -0
- package/dist/api/agents.test.js +289 -35
- package/dist/api/apps.test.d.ts +1 -0
- package/dist/api/apps.test.js +67 -0
- package/dist/api/chats.test.d.ts +1 -0
- package/dist/api/chats.test.js +33 -0
- package/dist/api/engines.test.d.ts +1 -0
- package/dist/api/engines.test.js +55 -0
- package/dist/api/files.test.js +3 -6
- package/dist/api/flow-runs.test.d.ts +1 -0
- package/dist/api/flow-runs.test.js +55 -0
- package/dist/api/flows.test.d.ts +1 -0
- package/dist/api/flows.test.js +43 -0
- package/dist/api/sessions.d.ts +2 -1
- package/dist/api/sessions.js +2 -1
- package/dist/api/sessions.test.d.ts +1 -0
- package/dist/api/sessions.test.js +49 -0
- package/dist/api/tasks.test.js +50 -18
- package/dist/client.test.js +8 -8
- package/dist/http/client.js +5 -26
- package/dist/http/client.test.js +51 -13
- package/dist/proxy/express.test.d.ts +1 -0
- package/dist/proxy/express.test.js +106 -0
- package/dist/proxy/index.test.js +10 -1
- package/dist/stream.test.js +139 -0
- package/dist/tool-builder.test.js +69 -2
- package/dist/types.d.ts +731 -30
- package/dist/types.js +154 -14
- package/package.json +11 -4
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { ChatStatusBusy, ToolInvocationStatusAwaitingInput, ToolTypeClient, } from '../types';
|
|
2
|
+
import { createActions } 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
|
+
});
|
|
98
|
+
describe('updateMessage (via stream listeners)', () => {
|
|
99
|
+
it('should ignore messages for a different chat when IDs do not prefix-match', async () => {
|
|
100
|
+
const { ctx, dispatch } = createTestContext({ getChatId: () => 'other-chat' });
|
|
101
|
+
const { internalActions } = createActions(ctx);
|
|
102
|
+
internalActions.streamChat('chat-full-id-123');
|
|
103
|
+
await Promise.resolve();
|
|
104
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
105
|
+
onMessage(makeMessage({ chat_id: 'unrelated-chat-id' }));
|
|
106
|
+
expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'UPDATE_MESSAGE' }));
|
|
107
|
+
});
|
|
108
|
+
it('should accept messages when chat_id is a prefix extension of the short chatId', async () => {
|
|
109
|
+
const { ctx, dispatch } = createTestContext({ getChatId: () => 'chat-short' });
|
|
110
|
+
const { internalActions } = createActions(ctx);
|
|
111
|
+
internalActions.streamChat('chat-short');
|
|
112
|
+
await Promise.resolve();
|
|
113
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
114
|
+
onMessage(makeMessage({ chat_id: 'chat-short-full-suffix' }));
|
|
115
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
116
|
+
type: 'UPDATE_MESSAGE',
|
|
117
|
+
payload: expect.objectContaining({ chat_id: 'chat-short-full-suffix' }),
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
it('should run the handler and submit its result when a client tool is available', async () => {
|
|
121
|
+
const handler = jest.fn().mockResolvedValue('tool ok');
|
|
122
|
+
const { ctx } = createTestContext({
|
|
123
|
+
getClientToolHandlers: () => new Map([['my_tool', handler]]),
|
|
124
|
+
});
|
|
125
|
+
const { internalActions } = createActions(ctx);
|
|
126
|
+
internalActions.streamChat('chat-full-id-123');
|
|
127
|
+
await Promise.resolve();
|
|
128
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
129
|
+
onMessage(makeMessage({
|
|
130
|
+
chat_id: 'chat-short',
|
|
131
|
+
tool_invocations: [
|
|
132
|
+
{
|
|
133
|
+
id: 'tool-inv-ok',
|
|
134
|
+
type: ToolTypeClient,
|
|
135
|
+
status: ToolInvocationStatusAwaitingInput,
|
|
136
|
+
function: { name: 'my_tool', arguments: { x: 1 } },
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
}));
|
|
140
|
+
await Promise.resolve();
|
|
141
|
+
expect(handler).toHaveBeenCalledWith({ x: 1 });
|
|
142
|
+
expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-inv-ok', 'tool ok');
|
|
143
|
+
});
|
|
144
|
+
it('should submit a JSON error when a client tool handler throws', async () => {
|
|
145
|
+
const handler = jest.fn().mockRejectedValue(new Error('handler boom'));
|
|
146
|
+
const { ctx } = createTestContext({
|
|
147
|
+
getClientToolHandlers: () => new Map([['my_tool', handler]]),
|
|
148
|
+
});
|
|
149
|
+
const { internalActions } = createActions(ctx);
|
|
150
|
+
internalActions.streamChat('chat-full-id-123');
|
|
151
|
+
await Promise.resolve();
|
|
152
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
153
|
+
onMessage(makeMessage({
|
|
154
|
+
chat_id: 'chat-short',
|
|
155
|
+
tool_invocations: [
|
|
156
|
+
{
|
|
157
|
+
id: 'tool-inv-err',
|
|
158
|
+
type: ToolTypeClient,
|
|
159
|
+
status: ToolInvocationStatusAwaitingInput,
|
|
160
|
+
function: { name: 'my_tool', arguments: {} },
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
}));
|
|
164
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
165
|
+
expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-inv-err', expect.stringContaining('handler boom'));
|
|
166
|
+
});
|
|
167
|
+
it('should submit not_available when a client tool has no handler', async () => {
|
|
168
|
+
const { ctx } = createTestContext({
|
|
169
|
+
getClientToolHandlers: () => new Map([['other_tool', jest.fn().mockResolvedValue('ok')]]),
|
|
170
|
+
});
|
|
171
|
+
const { internalActions } = createActions(ctx);
|
|
172
|
+
internalActions.streamChat('chat-full-id-123');
|
|
173
|
+
await Promise.resolve();
|
|
174
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
175
|
+
onMessage(makeMessage({
|
|
176
|
+
chat_id: 'chat-short',
|
|
177
|
+
tool_invocations: [
|
|
178
|
+
{
|
|
179
|
+
id: 'tool-missing-handler',
|
|
180
|
+
type: ToolTypeClient,
|
|
181
|
+
status: ToolInvocationStatusAwaitingInput,
|
|
182
|
+
function: { name: 'missing_tool', arguments: {} },
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
}));
|
|
186
|
+
await Promise.resolve();
|
|
187
|
+
expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-missing-handler', expect.stringContaining('not_available'));
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
describe('streamChat', () => {
|
|
191
|
+
it('should use PollManager when streaming is disabled', async () => {
|
|
192
|
+
const { ctx } = createTestContext({
|
|
193
|
+
getStreamEnabled: () => false,
|
|
194
|
+
});
|
|
195
|
+
const { internalActions } = createActions(ctx);
|
|
196
|
+
internalActions.streamChat('chat-full-id-123');
|
|
197
|
+
await Promise.resolve();
|
|
198
|
+
expect(PollManager).toHaveBeenCalled();
|
|
199
|
+
expect(StreamableManager).not.toHaveBeenCalled();
|
|
200
|
+
expect(pollInstances[0].options.pollFunction).toBeDefined();
|
|
201
|
+
expect(pollInstances[0].start).toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe('stopStream', () => {
|
|
205
|
+
it('should clear the manager ref before stop so onEnd does not double-dispatch idle', async () => {
|
|
206
|
+
const { ctx, dispatch, setStreamManager } = createTestContext();
|
|
207
|
+
const { internalActions } = createActions(ctx);
|
|
208
|
+
internalActions.streamChat('chat-full-id-123');
|
|
209
|
+
await Promise.resolve();
|
|
210
|
+
const manager = streamInstances[0];
|
|
211
|
+
internalActions.stopStream();
|
|
212
|
+
expect(setStreamManager).toHaveBeenCalledWith(undefined);
|
|
213
|
+
expect(manager.stop).toHaveBeenCalled();
|
|
214
|
+
manager.options.onEnd?.();
|
|
215
|
+
const idleDispatches = dispatch.mock.calls.filter(([action]) => action.type === 'SET_CONNECTION_STATUS' && action.payload === 'idle');
|
|
216
|
+
// Only the explicit stopStream dispatch, not a second from onEnd
|
|
217
|
+
expect(idleDispatches).toHaveLength(1);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe('publicActions.sendMessage', () => {
|
|
221
|
+
it('should call onChatCreated and start streaming for a new chat', async () => {
|
|
222
|
+
const onChatCreated = jest.fn();
|
|
223
|
+
const { ctx } = createTestContext({
|
|
224
|
+
getChatId: () => null,
|
|
225
|
+
callbacks: { onChatCreated },
|
|
226
|
+
});
|
|
227
|
+
const { publicActions } = createActions(ctx);
|
|
228
|
+
await publicActions.sendMessage('hello');
|
|
229
|
+
expect(onChatCreated).toHaveBeenCalledWith('chat-full-id-123');
|
|
230
|
+
expect(StreamableManager).toHaveBeenCalled();
|
|
231
|
+
});
|
|
232
|
+
it('should reset connection status when the API returns no result', async () => {
|
|
233
|
+
mockAgentApi.sendMessage.mockResolvedValueOnce(null);
|
|
234
|
+
const onStatusChange = jest.fn();
|
|
235
|
+
const { ctx, dispatch } = createTestContext({
|
|
236
|
+
callbacks: { onStatusChange },
|
|
237
|
+
});
|
|
238
|
+
const { publicActions } = createActions(ctx);
|
|
239
|
+
await publicActions.sendMessage('hello');
|
|
240
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
241
|
+
type: 'SET_CONNECTION_STATUS',
|
|
242
|
+
payload: 'idle',
|
|
243
|
+
});
|
|
244
|
+
expect(onStatusChange).toHaveBeenCalledWith('idle');
|
|
245
|
+
});
|
|
246
|
+
it('should dispatch error state when sendMessage throws', async () => {
|
|
247
|
+
mockAgentApi.sendMessage.mockRejectedValueOnce(new Error('send failed'));
|
|
248
|
+
const onError = jest.fn();
|
|
249
|
+
const { ctx, dispatch } = createTestContext({ callbacks: { onError } });
|
|
250
|
+
const { publicActions } = createActions(ctx);
|
|
251
|
+
await publicActions.sendMessage('hello');
|
|
252
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
253
|
+
type: 'SET_CONNECTION_STATUS',
|
|
254
|
+
payload: 'error',
|
|
255
|
+
});
|
|
256
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
257
|
+
type: 'SET_ERROR',
|
|
258
|
+
payload: 'send failed',
|
|
259
|
+
});
|
|
260
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'send failed' }));
|
|
261
|
+
});
|
|
262
|
+
it('should ignore whitespace-only messages', async () => {
|
|
263
|
+
const { ctx } = createTestContext();
|
|
264
|
+
const { publicActions } = createActions(ctx);
|
|
265
|
+
await publicActions.sendMessage(' ');
|
|
266
|
+
expect(mockAgentApi.sendMessage).not.toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
describe('streamChat error handling', () => {
|
|
270
|
+
it('should reset to idle when initial fetchChat fails', async () => {
|
|
271
|
+
mockAgentApi.fetchChat.mockRejectedValueOnce(new Error('fetch failed'));
|
|
272
|
+
const onStatusChange = jest.fn();
|
|
273
|
+
const { ctx, dispatch } = createTestContext({ callbacks: { onStatusChange } });
|
|
274
|
+
const { internalActions } = createActions(ctx);
|
|
275
|
+
await internalActions.streamChat('chat-full-id-123');
|
|
276
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
277
|
+
type: 'SET_CONNECTION_STATUS',
|
|
278
|
+
payload: 'idle',
|
|
279
|
+
});
|
|
280
|
+
expect(onStatusChange).toHaveBeenCalledWith('idle');
|
|
281
|
+
expect(StreamableManager).not.toHaveBeenCalled();
|
|
282
|
+
});
|
|
283
|
+
it('should dispatch UPDATE_CHAT when chats stream events arrive', async () => {
|
|
284
|
+
const onStatusChange = jest.fn();
|
|
285
|
+
const { ctx, dispatch } = createTestContext({ callbacks: { onStatusChange } });
|
|
286
|
+
const { internalActions } = createActions(ctx);
|
|
287
|
+
internalActions.streamChat('chat-full-id-123');
|
|
288
|
+
await Promise.resolve();
|
|
289
|
+
const onChat = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chats')?.[1];
|
|
290
|
+
onChat({ id: 'chat-full-id-123', status: ChatStatusBusy });
|
|
291
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
292
|
+
type: 'UPDATE_CHAT',
|
|
293
|
+
payload: expect.objectContaining({ status: ChatStatusBusy }),
|
|
294
|
+
});
|
|
295
|
+
expect(onStatusChange).toHaveBeenCalledWith('streaming');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
describe('pollChat', () => {
|
|
299
|
+
it('should fetch full chat when poll status changes', async () => {
|
|
300
|
+
const { ctx: baseCtx } = createTestContext({ getStreamEnabled: () => false });
|
|
301
|
+
const { ctx } = createTestContext({
|
|
302
|
+
getStreamEnabled: () => false,
|
|
303
|
+
client: {
|
|
304
|
+
...baseCtx.client,
|
|
305
|
+
http: { ...baseCtx.client.http, request: jest.fn().mockResolvedValue({ status: ChatStatusBusy }) },
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
const { internalActions } = createActions(ctx);
|
|
309
|
+
mockAgentApi.fetchChat
|
|
310
|
+
.mockResolvedValueOnce({
|
|
311
|
+
id: 'chat-full-id-123',
|
|
312
|
+
status: ChatStatusBusy,
|
|
313
|
+
chat_messages: [],
|
|
314
|
+
})
|
|
315
|
+
.mockResolvedValueOnce({
|
|
316
|
+
id: 'chat-full-id-123',
|
|
317
|
+
status: ChatStatusBusy,
|
|
318
|
+
chat_messages: [makeMessage()],
|
|
319
|
+
});
|
|
320
|
+
internalActions.streamChat('chat-full-id-123');
|
|
321
|
+
await Promise.resolve();
|
|
322
|
+
await pollInstances[0].options.onData?.({ status: 'idle' });
|
|
323
|
+
await Promise.resolve();
|
|
324
|
+
expect(mockAgentApi.fetchChat).toHaveBeenCalledTimes(2);
|
|
325
|
+
});
|
|
326
|
+
it('should call onError when poll fetch fails', async () => {
|
|
327
|
+
const onError = jest.fn();
|
|
328
|
+
const { ctx: baseCtx } = createTestContext({ getStreamEnabled: () => false });
|
|
329
|
+
const { ctx } = createTestContext({
|
|
330
|
+
getStreamEnabled: () => false,
|
|
331
|
+
callbacks: { onError },
|
|
332
|
+
client: {
|
|
333
|
+
...baseCtx.client,
|
|
334
|
+
http: { ...baseCtx.client.http, request: jest.fn().mockResolvedValue({ status: ChatStatusBusy }) },
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
const { internalActions } = createActions(ctx);
|
|
338
|
+
mockAgentApi.fetchChat
|
|
339
|
+
.mockResolvedValueOnce({
|
|
340
|
+
id: 'chat-full-id-123',
|
|
341
|
+
status: ChatStatusBusy,
|
|
342
|
+
chat_messages: [],
|
|
343
|
+
})
|
|
344
|
+
.mockRejectedValueOnce(new Error('poll fetch failed'));
|
|
345
|
+
internalActions.streamChat('chat-full-id-123');
|
|
346
|
+
await Promise.resolve();
|
|
347
|
+
await pollInstances[0].options.onData?.({ status: 'idle' });
|
|
348
|
+
await Promise.resolve();
|
|
349
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'poll fetch failed' }));
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
describe('client tool deduplication', () => {
|
|
353
|
+
it('should not submit the same client tool invocation twice', async () => {
|
|
354
|
+
const handler = jest.fn().mockResolvedValue('ok');
|
|
355
|
+
const { ctx } = createTestContext({
|
|
356
|
+
getClientToolHandlers: () => new Map([['my_tool', handler]]),
|
|
357
|
+
});
|
|
358
|
+
const { internalActions } = createActions(ctx);
|
|
359
|
+
internalActions.streamChat('chat-full-id-123');
|
|
360
|
+
await Promise.resolve();
|
|
361
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
362
|
+
const toolMessage = makeMessage({
|
|
363
|
+
chat_id: 'chat-short',
|
|
364
|
+
tool_invocations: [
|
|
365
|
+
{
|
|
366
|
+
id: 'tool-inv-dup',
|
|
367
|
+
type: ToolTypeClient,
|
|
368
|
+
status: ToolInvocationStatusAwaitingInput,
|
|
369
|
+
function: { name: 'my_tool', arguments: {} },
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
});
|
|
373
|
+
onMessage(toolMessage);
|
|
374
|
+
onMessage(toolMessage);
|
|
375
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
376
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
377
|
+
expect(mockAgentApi.submitToolResult).toHaveBeenCalledTimes(1);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
describe('publicActions lifecycle', () => {
|
|
381
|
+
it('reset should stop stream and dispatch RESET', async () => {
|
|
382
|
+
const { ctx, dispatch } = createTestContext();
|
|
383
|
+
const { publicActions } = createActions(ctx);
|
|
384
|
+
await publicActions.sendMessage('hello');
|
|
385
|
+
publicActions.reset();
|
|
386
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'RESET' });
|
|
387
|
+
});
|
|
388
|
+
it('stopGeneration should call stopChat when chatId exists', async () => {
|
|
389
|
+
const { ctx } = createTestContext({ getChatId: () => 'chat-short' });
|
|
390
|
+
const { publicActions } = createActions(ctx);
|
|
391
|
+
publicActions.stopGeneration();
|
|
392
|
+
expect(mockAgentApi.stopChat).toHaveBeenCalledWith(ctx.client, 'chat-short');
|
|
393
|
+
});
|
|
394
|
+
it('submitToolResult should set error state when API fails', async () => {
|
|
395
|
+
mockAgentApi.submitToolResult.mockRejectedValueOnce(new Error('submit failed'));
|
|
396
|
+
const onError = jest.fn();
|
|
397
|
+
const { ctx, dispatch } = createTestContext({ callbacks: { onError } });
|
|
398
|
+
const { publicActions } = createActions(ctx);
|
|
399
|
+
await expect(publicActions.submitToolResult('inv-1', 'result')).rejects.toThrow('submit failed');
|
|
400
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
401
|
+
type: 'SET_CONNECTION_STATUS',
|
|
402
|
+
payload: 'error',
|
|
403
|
+
});
|
|
404
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'submit failed' }));
|
|
405
|
+
});
|
|
406
|
+
it('clearError should reset error and connection status to idle', () => {
|
|
407
|
+
const { ctx, dispatch } = createTestContext();
|
|
408
|
+
const { publicActions } = createActions(ctx);
|
|
409
|
+
publicActions.clearError();
|
|
410
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
411
|
+
type: 'SET_ERROR',
|
|
412
|
+
payload: undefined,
|
|
413
|
+
});
|
|
414
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
415
|
+
type: 'SET_CONNECTION_STATUS',
|
|
416
|
+
payload: 'idle',
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
describe('HIL tool actions', () => {
|
|
421
|
+
it('approveTool should delegate to the API', async () => {
|
|
422
|
+
const { ctx } = createTestContext();
|
|
423
|
+
const { publicActions } = createActions(ctx);
|
|
424
|
+
await publicActions.approveTool('inv-approve');
|
|
425
|
+
expect(mockAgentApi.approveTool).toHaveBeenCalledWith(ctx.client, 'inv-approve');
|
|
426
|
+
});
|
|
427
|
+
it('rejectTool should pass an optional reason', async () => {
|
|
428
|
+
const { ctx } = createTestContext();
|
|
429
|
+
const { publicActions } = createActions(ctx);
|
|
430
|
+
await publicActions.rejectTool('inv-reject', 'unsafe');
|
|
431
|
+
expect(mockAgentApi.rejectTool).toHaveBeenCalledWith(ctx.client, 'inv-reject', 'unsafe');
|
|
432
|
+
});
|
|
433
|
+
it('alwaysAllowTool should no-op without a chatId', async () => {
|
|
434
|
+
const { ctx } = createTestContext({ getChatId: () => null });
|
|
435
|
+
const { publicActions } = createActions(ctx);
|
|
436
|
+
await publicActions.alwaysAllowTool('inv-allow', 'my_tool');
|
|
437
|
+
expect(mockAgentApi.alwaysAllowTool).not.toHaveBeenCalled();
|
|
438
|
+
});
|
|
439
|
+
it('alwaysAllowTool should call API when chatId exists', async () => {
|
|
440
|
+
const { ctx } = createTestContext({ getChatId: () => 'chat-short' });
|
|
441
|
+
const { publicActions } = createActions(ctx);
|
|
442
|
+
await publicActions.alwaysAllowTool('inv-allow', 'my_tool');
|
|
443
|
+
expect(mockAgentApi.alwaysAllowTool).toHaveBeenCalledWith(ctx.client, 'chat-short', 'inv-allow', 'my_tool');
|
|
444
|
+
});
|
|
445
|
+
it('approveTool should set error state when API fails', async () => {
|
|
446
|
+
mockAgentApi.approveTool.mockRejectedValueOnce(new Error('approve failed'));
|
|
447
|
+
const onError = jest.fn();
|
|
448
|
+
const { ctx, dispatch } = createTestContext({ callbacks: { onError } });
|
|
449
|
+
const { publicActions } = createActions(ctx);
|
|
450
|
+
await expect(publicActions.approveTool('inv-1')).rejects.toThrow('approve failed');
|
|
451
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
452
|
+
type: 'SET_CONNECTION_STATUS',
|
|
453
|
+
payload: 'error',
|
|
454
|
+
});
|
|
455
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'approve failed' }));
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
describe('setChatId', () => {
|
|
459
|
+
it('should no-op when the chat id is unchanged', async () => {
|
|
460
|
+
const { ctx } = createTestContext({ getChatId: () => 'chat-short' });
|
|
461
|
+
const { internalActions } = createActions(ctx);
|
|
462
|
+
internalActions.setChatId('chat-short');
|
|
463
|
+
await Promise.resolve();
|
|
464
|
+
expect(StreamableManager).not.toHaveBeenCalled();
|
|
465
|
+
});
|
|
466
|
+
it('should reset and stop stream when chat id is cleared', async () => {
|
|
467
|
+
const { ctx, dispatch } = createTestContext();
|
|
468
|
+
const { internalActions } = createActions(ctx);
|
|
469
|
+
internalActions.streamChat('chat-full-id-123');
|
|
470
|
+
await Promise.resolve();
|
|
471
|
+
internalActions.setChatId(null);
|
|
472
|
+
expect(streamInstances[0].stop).toHaveBeenCalled();
|
|
473
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'RESET' });
|
|
474
|
+
});
|
|
475
|
+
it('should start streaming when switching to a new chat id', async () => {
|
|
476
|
+
const { ctx, dispatch } = createTestContext({ getChatId: () => null });
|
|
477
|
+
const { internalActions } = createActions(ctx);
|
|
478
|
+
internalActions.setChatId('chat-new');
|
|
479
|
+
await Promise.resolve();
|
|
480
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
481
|
+
type: 'SET_CHAT_ID',
|
|
482
|
+
payload: 'chat-new',
|
|
483
|
+
});
|
|
484
|
+
expect(StreamableManager).toHaveBeenCalled();
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|