@inferencesh/sdk 0.6.7 → 0.6.8
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 +26 -1
- package/README.md +236 -36
- package/dist/agent/actions.test.d.ts +1 -0
- package/dist/agent/actions.test.js +404 -0
- package/dist/agent/api.test.d.ts +1 -0
- package/dist/agent/api.test.js +130 -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 +258 -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 +14 -0
- package/dist/stream.test.js +139 -0
- package/dist/tool-builder.test.js +69 -2
- package/dist/types.d.ts +48 -12
- package/dist/types.js +32 -8
- package/package.json +11 -4
|
@@ -0,0 +1,404 @@
|
|
|
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
|
+
});
|
|
95
|
+
describe('updateMessage (via stream listeners)', () => {
|
|
96
|
+
it('should ignore messages for a different chat when IDs do not prefix-match', async () => {
|
|
97
|
+
const { ctx, dispatch } = createTestContext({ getChatId: () => 'other-chat' });
|
|
98
|
+
const { internalActions } = createActions(ctx);
|
|
99
|
+
internalActions.streamChat('chat-full-id-123');
|
|
100
|
+
await Promise.resolve();
|
|
101
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
102
|
+
onMessage(makeMessage({ chat_id: 'unrelated-chat-id' }));
|
|
103
|
+
expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'UPDATE_MESSAGE' }));
|
|
104
|
+
});
|
|
105
|
+
it('should accept messages when chat_id is a prefix extension of the short chatId', async () => {
|
|
106
|
+
const { ctx, dispatch } = createTestContext({ getChatId: () => 'chat-short' });
|
|
107
|
+
const { internalActions } = createActions(ctx);
|
|
108
|
+
internalActions.streamChat('chat-short');
|
|
109
|
+
await Promise.resolve();
|
|
110
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
111
|
+
onMessage(makeMessage({ chat_id: 'chat-short-full-suffix' }));
|
|
112
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
113
|
+
type: 'UPDATE_MESSAGE',
|
|
114
|
+
payload: expect.objectContaining({ chat_id: 'chat-short-full-suffix' }),
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
it('should run the handler and submit its result when a client tool is available', async () => {
|
|
118
|
+
const handler = jest.fn().mockResolvedValue('tool ok');
|
|
119
|
+
const { ctx } = createTestContext({
|
|
120
|
+
getClientToolHandlers: () => new Map([['my_tool', handler]]),
|
|
121
|
+
});
|
|
122
|
+
const { internalActions } = createActions(ctx);
|
|
123
|
+
internalActions.streamChat('chat-full-id-123');
|
|
124
|
+
await Promise.resolve();
|
|
125
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
126
|
+
onMessage(makeMessage({
|
|
127
|
+
chat_id: 'chat-short',
|
|
128
|
+
tool_invocations: [
|
|
129
|
+
{
|
|
130
|
+
id: 'tool-inv-ok',
|
|
131
|
+
type: ToolTypeClient,
|
|
132
|
+
status: ToolInvocationStatusAwaitingInput,
|
|
133
|
+
function: { name: 'my_tool', arguments: { x: 1 } },
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
}));
|
|
137
|
+
await Promise.resolve();
|
|
138
|
+
expect(handler).toHaveBeenCalledWith({ x: 1 });
|
|
139
|
+
expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-inv-ok', 'tool ok');
|
|
140
|
+
});
|
|
141
|
+
it('should submit a JSON error when a client tool handler throws', async () => {
|
|
142
|
+
const handler = jest.fn().mockRejectedValue(new Error('handler boom'));
|
|
143
|
+
const { ctx } = createTestContext({
|
|
144
|
+
getClientToolHandlers: () => new Map([['my_tool', handler]]),
|
|
145
|
+
});
|
|
146
|
+
const { internalActions } = createActions(ctx);
|
|
147
|
+
internalActions.streamChat('chat-full-id-123');
|
|
148
|
+
await Promise.resolve();
|
|
149
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
150
|
+
onMessage(makeMessage({
|
|
151
|
+
chat_id: 'chat-short',
|
|
152
|
+
tool_invocations: [
|
|
153
|
+
{
|
|
154
|
+
id: 'tool-inv-err',
|
|
155
|
+
type: ToolTypeClient,
|
|
156
|
+
status: ToolInvocationStatusAwaitingInput,
|
|
157
|
+
function: { name: 'my_tool', arguments: {} },
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
}));
|
|
161
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
162
|
+
expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-inv-err', expect.stringContaining('handler boom'));
|
|
163
|
+
});
|
|
164
|
+
it('should submit not_available when a client tool has no handler', async () => {
|
|
165
|
+
const { ctx } = createTestContext({
|
|
166
|
+
getClientToolHandlers: () => new Map([['other_tool', jest.fn().mockResolvedValue('ok')]]),
|
|
167
|
+
});
|
|
168
|
+
const { internalActions } = createActions(ctx);
|
|
169
|
+
internalActions.streamChat('chat-full-id-123');
|
|
170
|
+
await Promise.resolve();
|
|
171
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
172
|
+
onMessage(makeMessage({
|
|
173
|
+
chat_id: 'chat-short',
|
|
174
|
+
tool_invocations: [
|
|
175
|
+
{
|
|
176
|
+
id: 'tool-missing-handler',
|
|
177
|
+
type: ToolTypeClient,
|
|
178
|
+
status: ToolInvocationStatusAwaitingInput,
|
|
179
|
+
function: { name: 'missing_tool', arguments: {} },
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
}));
|
|
183
|
+
await Promise.resolve();
|
|
184
|
+
expect(mockAgentApi.submitToolResult).toHaveBeenCalledWith(ctx.client, 'tool-missing-handler', expect.stringContaining('not_available'));
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
describe('streamChat', () => {
|
|
188
|
+
it('should use PollManager when streaming is disabled', async () => {
|
|
189
|
+
const { ctx } = createTestContext({
|
|
190
|
+
getStreamEnabled: () => false,
|
|
191
|
+
});
|
|
192
|
+
const { internalActions } = createActions(ctx);
|
|
193
|
+
internalActions.streamChat('chat-full-id-123');
|
|
194
|
+
await Promise.resolve();
|
|
195
|
+
expect(PollManager).toHaveBeenCalled();
|
|
196
|
+
expect(StreamableManager).not.toHaveBeenCalled();
|
|
197
|
+
expect(pollInstances[0].options.pollFunction).toBeDefined();
|
|
198
|
+
expect(pollInstances[0].start).toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
describe('stopStream', () => {
|
|
202
|
+
it('should clear the manager ref before stop so onEnd does not double-dispatch idle', async () => {
|
|
203
|
+
const { ctx, dispatch, setStreamManager } = createTestContext();
|
|
204
|
+
const { internalActions } = createActions(ctx);
|
|
205
|
+
internalActions.streamChat('chat-full-id-123');
|
|
206
|
+
await Promise.resolve();
|
|
207
|
+
const manager = streamInstances[0];
|
|
208
|
+
internalActions.stopStream();
|
|
209
|
+
expect(setStreamManager).toHaveBeenCalledWith(undefined);
|
|
210
|
+
expect(manager.stop).toHaveBeenCalled();
|
|
211
|
+
manager.options.onEnd?.();
|
|
212
|
+
const idleDispatches = dispatch.mock.calls.filter(([action]) => action.type === 'SET_CONNECTION_STATUS' && action.payload === 'idle');
|
|
213
|
+
// Only the explicit stopStream dispatch, not a second from onEnd
|
|
214
|
+
expect(idleDispatches).toHaveLength(1);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
describe('publicActions.sendMessage', () => {
|
|
218
|
+
it('should call onChatCreated and start streaming for a new chat', async () => {
|
|
219
|
+
const onChatCreated = jest.fn();
|
|
220
|
+
const { ctx } = createTestContext({
|
|
221
|
+
getChatId: () => null,
|
|
222
|
+
callbacks: { onChatCreated },
|
|
223
|
+
});
|
|
224
|
+
const { publicActions } = createActions(ctx);
|
|
225
|
+
await publicActions.sendMessage('hello');
|
|
226
|
+
expect(onChatCreated).toHaveBeenCalledWith('chat-full-id-123');
|
|
227
|
+
expect(StreamableManager).toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
it('should reset connection status when the API returns no result', async () => {
|
|
230
|
+
mockAgentApi.sendMessage.mockResolvedValueOnce(null);
|
|
231
|
+
const onStatusChange = jest.fn();
|
|
232
|
+
const { ctx, dispatch } = createTestContext({
|
|
233
|
+
callbacks: { onStatusChange },
|
|
234
|
+
});
|
|
235
|
+
const { publicActions } = createActions(ctx);
|
|
236
|
+
await publicActions.sendMessage('hello');
|
|
237
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
238
|
+
type: 'SET_CONNECTION_STATUS',
|
|
239
|
+
payload: 'idle',
|
|
240
|
+
});
|
|
241
|
+
expect(onStatusChange).toHaveBeenCalledWith('idle');
|
|
242
|
+
});
|
|
243
|
+
it('should dispatch error state when sendMessage throws', async () => {
|
|
244
|
+
mockAgentApi.sendMessage.mockRejectedValueOnce(new Error('send failed'));
|
|
245
|
+
const onError = jest.fn();
|
|
246
|
+
const { ctx, dispatch } = createTestContext({ callbacks: { onError } });
|
|
247
|
+
const { publicActions } = createActions(ctx);
|
|
248
|
+
await publicActions.sendMessage('hello');
|
|
249
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
250
|
+
type: 'SET_CONNECTION_STATUS',
|
|
251
|
+
payload: 'error',
|
|
252
|
+
});
|
|
253
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
254
|
+
type: 'SET_ERROR',
|
|
255
|
+
payload: 'send failed',
|
|
256
|
+
});
|
|
257
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'send failed' }));
|
|
258
|
+
});
|
|
259
|
+
it('should ignore whitespace-only messages', async () => {
|
|
260
|
+
const { ctx } = createTestContext();
|
|
261
|
+
const { publicActions } = createActions(ctx);
|
|
262
|
+
await publicActions.sendMessage(' ');
|
|
263
|
+
expect(mockAgentApi.sendMessage).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
describe('streamChat error handling', () => {
|
|
267
|
+
it('should reset to idle when initial fetchChat fails', async () => {
|
|
268
|
+
mockAgentApi.fetchChat.mockRejectedValueOnce(new Error('fetch failed'));
|
|
269
|
+
const onStatusChange = jest.fn();
|
|
270
|
+
const { ctx, dispatch } = createTestContext({ callbacks: { onStatusChange } });
|
|
271
|
+
const { internalActions } = createActions(ctx);
|
|
272
|
+
await internalActions.streamChat('chat-full-id-123');
|
|
273
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
274
|
+
type: 'SET_CONNECTION_STATUS',
|
|
275
|
+
payload: 'idle',
|
|
276
|
+
});
|
|
277
|
+
expect(onStatusChange).toHaveBeenCalledWith('idle');
|
|
278
|
+
expect(StreamableManager).not.toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
it('should dispatch UPDATE_CHAT when chats stream events arrive', async () => {
|
|
281
|
+
const onStatusChange = jest.fn();
|
|
282
|
+
const { ctx, dispatch } = createTestContext({ callbacks: { onStatusChange } });
|
|
283
|
+
const { internalActions } = createActions(ctx);
|
|
284
|
+
internalActions.streamChat('chat-full-id-123');
|
|
285
|
+
await Promise.resolve();
|
|
286
|
+
const onChat = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chats')?.[1];
|
|
287
|
+
onChat({ id: 'chat-full-id-123', status: ChatStatusBusy });
|
|
288
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
289
|
+
type: 'UPDATE_CHAT',
|
|
290
|
+
payload: expect.objectContaining({ status: ChatStatusBusy }),
|
|
291
|
+
});
|
|
292
|
+
expect(onStatusChange).toHaveBeenCalledWith('streaming');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
describe('pollChat', () => {
|
|
296
|
+
it('should fetch full chat when poll status changes', async () => {
|
|
297
|
+
const { ctx: baseCtx } = createTestContext({ getStreamEnabled: () => false });
|
|
298
|
+
const { ctx } = createTestContext({
|
|
299
|
+
getStreamEnabled: () => false,
|
|
300
|
+
client: {
|
|
301
|
+
...baseCtx.client,
|
|
302
|
+
http: { ...baseCtx.client.http, request: jest.fn().mockResolvedValue({ status: ChatStatusBusy }) },
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const { internalActions } = createActions(ctx);
|
|
306
|
+
mockAgentApi.fetchChat
|
|
307
|
+
.mockResolvedValueOnce({
|
|
308
|
+
id: 'chat-full-id-123',
|
|
309
|
+
status: ChatStatusBusy,
|
|
310
|
+
chat_messages: [],
|
|
311
|
+
})
|
|
312
|
+
.mockResolvedValueOnce({
|
|
313
|
+
id: 'chat-full-id-123',
|
|
314
|
+
status: ChatStatusBusy,
|
|
315
|
+
chat_messages: [makeMessage()],
|
|
316
|
+
});
|
|
317
|
+
internalActions.streamChat('chat-full-id-123');
|
|
318
|
+
await Promise.resolve();
|
|
319
|
+
await pollInstances[0].options.onData?.({ status: 'idle' });
|
|
320
|
+
await Promise.resolve();
|
|
321
|
+
expect(mockAgentApi.fetchChat).toHaveBeenCalledTimes(2);
|
|
322
|
+
});
|
|
323
|
+
it('should call onError when poll fetch fails', async () => {
|
|
324
|
+
const onError = jest.fn();
|
|
325
|
+
const { ctx: baseCtx } = createTestContext({ getStreamEnabled: () => false });
|
|
326
|
+
const { ctx } = createTestContext({
|
|
327
|
+
getStreamEnabled: () => false,
|
|
328
|
+
callbacks: { onError },
|
|
329
|
+
client: {
|
|
330
|
+
...baseCtx.client,
|
|
331
|
+
http: { ...baseCtx.client.http, request: jest.fn().mockResolvedValue({ status: ChatStatusBusy }) },
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
const { internalActions } = createActions(ctx);
|
|
335
|
+
mockAgentApi.fetchChat
|
|
336
|
+
.mockResolvedValueOnce({
|
|
337
|
+
id: 'chat-full-id-123',
|
|
338
|
+
status: ChatStatusBusy,
|
|
339
|
+
chat_messages: [],
|
|
340
|
+
})
|
|
341
|
+
.mockRejectedValueOnce(new Error('poll fetch failed'));
|
|
342
|
+
internalActions.streamChat('chat-full-id-123');
|
|
343
|
+
await Promise.resolve();
|
|
344
|
+
await pollInstances[0].options.onData?.({ status: 'idle' });
|
|
345
|
+
await Promise.resolve();
|
|
346
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'poll fetch failed' }));
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
describe('client tool deduplication', () => {
|
|
350
|
+
it('should not submit the same client tool invocation twice', async () => {
|
|
351
|
+
const handler = jest.fn().mockResolvedValue('ok');
|
|
352
|
+
const { ctx } = createTestContext({
|
|
353
|
+
getClientToolHandlers: () => new Map([['my_tool', handler]]),
|
|
354
|
+
});
|
|
355
|
+
const { internalActions } = createActions(ctx);
|
|
356
|
+
internalActions.streamChat('chat-full-id-123');
|
|
357
|
+
await Promise.resolve();
|
|
358
|
+
const onMessage = streamInstances[0].addEventListener.mock.calls.find(([event]) => event === 'chat_messages')?.[1];
|
|
359
|
+
const toolMessage = makeMessage({
|
|
360
|
+
chat_id: 'chat-short',
|
|
361
|
+
tool_invocations: [
|
|
362
|
+
{
|
|
363
|
+
id: 'tool-inv-dup',
|
|
364
|
+
type: ToolTypeClient,
|
|
365
|
+
status: ToolInvocationStatusAwaitingInput,
|
|
366
|
+
function: { name: 'my_tool', arguments: {} },
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
});
|
|
370
|
+
onMessage(toolMessage);
|
|
371
|
+
onMessage(toolMessage);
|
|
372
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
373
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
374
|
+
expect(mockAgentApi.submitToolResult).toHaveBeenCalledTimes(1);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
describe('publicActions lifecycle', () => {
|
|
378
|
+
it('reset should stop stream and dispatch RESET', async () => {
|
|
379
|
+
const { ctx, dispatch } = createTestContext();
|
|
380
|
+
const { publicActions } = createActions(ctx);
|
|
381
|
+
await publicActions.sendMessage('hello');
|
|
382
|
+
publicActions.reset();
|
|
383
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'RESET' });
|
|
384
|
+
});
|
|
385
|
+
it('stopGeneration should call stopChat when chatId exists', async () => {
|
|
386
|
+
const { ctx } = createTestContext({ getChatId: () => 'chat-short' });
|
|
387
|
+
const { publicActions } = createActions(ctx);
|
|
388
|
+
publicActions.stopGeneration();
|
|
389
|
+
expect(mockAgentApi.stopChat).toHaveBeenCalledWith(ctx.client, 'chat-short');
|
|
390
|
+
});
|
|
391
|
+
it('submitToolResult should set error state when API fails', async () => {
|
|
392
|
+
mockAgentApi.submitToolResult.mockRejectedValueOnce(new Error('submit failed'));
|
|
393
|
+
const onError = jest.fn();
|
|
394
|
+
const { ctx, dispatch } = createTestContext({ callbacks: { onError } });
|
|
395
|
+
const { publicActions } = createActions(ctx);
|
|
396
|
+
await expect(publicActions.submitToolResult('inv-1', 'result')).rejects.toThrow('submit failed');
|
|
397
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
398
|
+
type: 'SET_CONNECTION_STATUS',
|
|
399
|
+
payload: 'error',
|
|
400
|
+
});
|
|
401
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'submit failed' }));
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { HttpClient } from '../http/client';
|
|
2
|
+
import { FilesAPI } from '../api/files';
|
|
3
|
+
import { sendAdHocMessage, sendTemplateMessage, sendMessage, submitToolResult, getChatStreamConfig, } from './api';
|
|
4
|
+
import { ToolTypeClient } from '../types';
|
|
5
|
+
const mockFetch = jest.fn();
|
|
6
|
+
global.fetch = mockFetch;
|
|
7
|
+
function mockJsonResponse(body) {
|
|
8
|
+
mockFetch.mockResolvedValueOnce({
|
|
9
|
+
ok: true,
|
|
10
|
+
status: 200,
|
|
11
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function makeClient() {
|
|
15
|
+
const http = new HttpClient({ apiKey: 'test-key' });
|
|
16
|
+
const files = new FilesAPI(http);
|
|
17
|
+
return { http, files };
|
|
18
|
+
}
|
|
19
|
+
const adHocConfig = {
|
|
20
|
+
name: 'test-agent',
|
|
21
|
+
core_app: { ref: 'openrouter/claude@abc' },
|
|
22
|
+
system_prompt: 'Be helpful',
|
|
23
|
+
};
|
|
24
|
+
const runResponse = {
|
|
25
|
+
user_message: { id: 'u1', chat_id: 'chat-1', role: 'user' },
|
|
26
|
+
assistant_message: { id: 'a1', chat_id: 'chat-1', role: 'assistant' },
|
|
27
|
+
};
|
|
28
|
+
describe('agent/api', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
jest.clearAllMocks();
|
|
31
|
+
});
|
|
32
|
+
describe('sendAdHocMessage', () => {
|
|
33
|
+
it('should strip client tool handlers from the agents/run request body', async () => {
|
|
34
|
+
mockJsonResponse({ success: true, data: runResponse });
|
|
35
|
+
const handler = jest.fn().mockReturnValue('ok');
|
|
36
|
+
await sendAdHocMessage(makeClient(), {
|
|
37
|
+
...adHocConfig,
|
|
38
|
+
tools: [
|
|
39
|
+
{
|
|
40
|
+
schema: {
|
|
41
|
+
name: 'browser_tool',
|
|
42
|
+
type: ToolTypeClient,
|
|
43
|
+
description: 'runs in browser',
|
|
44
|
+
},
|
|
45
|
+
handler,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
}, null, 'hello');
|
|
49
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
50
|
+
const body = JSON.parse(String(init.body));
|
|
51
|
+
expect(body.agent_config.tools[0]).toEqual({
|
|
52
|
+
name: 'browser_tool',
|
|
53
|
+
type: ToolTypeClient,
|
|
54
|
+
description: 'runs in browser',
|
|
55
|
+
});
|
|
56
|
+
expect(body.agent_config.tools[0]).not.toHaveProperty('handler');
|
|
57
|
+
expect(handler).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('sendTemplateMessage', () => {
|
|
61
|
+
it('should omit empty agent field for existing chats', async () => {
|
|
62
|
+
mockJsonResponse({ success: true, data: runResponse });
|
|
63
|
+
await sendTemplateMessage(makeClient(), { agent: '' }, 'chat-existing', 'hi');
|
|
64
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
65
|
+
const body = JSON.parse(String(init.body));
|
|
66
|
+
expect(body.chat_id).toBe('chat-existing');
|
|
67
|
+
expect(body).not.toHaveProperty('agent');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('sendMessage', () => {
|
|
71
|
+
it('should pass FileRef attachments without uploading', async () => {
|
|
72
|
+
mockJsonResponse({ success: true, data: runResponse });
|
|
73
|
+
const fileRef = {
|
|
74
|
+
id: 'f1',
|
|
75
|
+
uri: 'inf://files/abc',
|
|
76
|
+
filename: 'image.png',
|
|
77
|
+
content_type: 'image/png',
|
|
78
|
+
};
|
|
79
|
+
await sendMessage(makeClient(), adHocConfig, null, 'see image', [fileRef]);
|
|
80
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
81
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
82
|
+
const body = JSON.parse(String(init.body));
|
|
83
|
+
expect(body.input.attachments).toEqual([fileRef]);
|
|
84
|
+
});
|
|
85
|
+
it('should upload File inputs before sending', async () => {
|
|
86
|
+
const fileRecord = {
|
|
87
|
+
id: 'file-1',
|
|
88
|
+
uri: 'inf://files/uploaded',
|
|
89
|
+
filename: 'hello.txt',
|
|
90
|
+
upload_url: 'https://upload.example/put',
|
|
91
|
+
content_type: 'text/plain',
|
|
92
|
+
};
|
|
93
|
+
mockJsonResponse({ success: true, data: [fileRecord] });
|
|
94
|
+
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
|
95
|
+
mockJsonResponse({ success: true, data: runResponse });
|
|
96
|
+
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });
|
|
97
|
+
await sendMessage(makeClient(), adHocConfig, null, 'with file', [file]);
|
|
98
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
99
|
+
const [, runInit] = mockFetch.mock.calls[2];
|
|
100
|
+
const body = JSON.parse(String(runInit.body));
|
|
101
|
+
expect(body.input.attachments).toEqual([fileRecord]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('submitToolResult', () => {
|
|
105
|
+
it('should wrap string results in { result }', async () => {
|
|
106
|
+
mockJsonResponse({ success: true, data: null });
|
|
107
|
+
await submitToolResult(makeClient(), 'inv-1', 'done');
|
|
108
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
109
|
+
expect(url).toContain('/tools/inv-1');
|
|
110
|
+
expect(JSON.parse(String(init.body))).toEqual({ result: 'done' });
|
|
111
|
+
});
|
|
112
|
+
it('should pass structured action objects through unchanged', async () => {
|
|
113
|
+
mockJsonResponse({ success: true, data: null });
|
|
114
|
+
const payload = {
|
|
115
|
+
action: { type: 'approve', payload: { ok: true } },
|
|
116
|
+
};
|
|
117
|
+
await submitToolResult(makeClient(), 'inv-2', payload);
|
|
118
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
119
|
+
expect(JSON.parse(String(init.body))).toEqual(payload);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('getChatStreamConfig', () => {
|
|
123
|
+
it('should delegate to HttpClient.getStreamableConfig for the chat stream path', () => {
|
|
124
|
+
const client = makeClient();
|
|
125
|
+
const config = getChatStreamConfig(client, 'chat-xyz');
|
|
126
|
+
expect(config.url).toContain('/chats/chat-xyz/stream');
|
|
127
|
+
expect(config.headers).toEqual(expect.objectContaining({ Authorization: expect.stringContaining('Bearer') }));
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -124,4 +124,8 @@ describe('chatReducer', () => {
|
|
|
124
124
|
});
|
|
125
125
|
expect(errored.error).toBe('stream failed');
|
|
126
126
|
});
|
|
127
|
+
it('should return the same state for unknown action types', () => {
|
|
128
|
+
const state = chatReducer(initialState, { type: 'UNKNOWN' });
|
|
129
|
+
expect(state).toBe(initialState);
|
|
130
|
+
});
|
|
127
131
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ToolTypeClient } from '../types';
|
|
2
|
+
import { isAdHocConfig, isTemplateConfig, isClientTool, extractToolSchemas, extractClientToolHandlers, } from './types';
|
|
3
|
+
describe('agent/types helpers', () => {
|
|
4
|
+
const adHocConfig = {
|
|
5
|
+
core_app: { ref: 'openrouter/claude@abc' },
|
|
6
|
+
system_prompt: 'test',
|
|
7
|
+
};
|
|
8
|
+
const templateConfig = {
|
|
9
|
+
agent: 'acme/support@latest',
|
|
10
|
+
};
|
|
11
|
+
describe('isAdHocConfig', () => {
|
|
12
|
+
it('returns true when core_app is present', () => {
|
|
13
|
+
expect(isAdHocConfig(adHocConfig)).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
it('returns false for template config', () => {
|
|
16
|
+
expect(isAdHocConfig(templateConfig)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe('isTemplateConfig', () => {
|
|
20
|
+
it('returns true when agent reference is present', () => {
|
|
21
|
+
expect(isTemplateConfig(templateConfig)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it('returns false for ad-hoc config', () => {
|
|
24
|
+
expect(isTemplateConfig(adHocConfig)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('isClientTool', () => {
|
|
28
|
+
it('detects client tools with schema and handler', () => {
|
|
29
|
+
const clientTool = {
|
|
30
|
+
schema: { name: 'browser_tool', type: ToolTypeClient, description: 'x' },
|
|
31
|
+
handler: jest.fn(),
|
|
32
|
+
};
|
|
33
|
+
expect(isClientTool(clientTool)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it('returns false for plain AgentTool schemas', () => {
|
|
36
|
+
expect(isClientTool({ name: 'server_tool', type: ToolTypeClient, description: 'x' })).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('extractToolSchemas', () => {
|
|
40
|
+
it('unwraps client tools to schemas only', () => {
|
|
41
|
+
const handler = jest.fn();
|
|
42
|
+
const schemas = extractToolSchemas([
|
|
43
|
+
{ name: 'server', type: ToolTypeClient, description: 's' },
|
|
44
|
+
{
|
|
45
|
+
schema: { name: 'client', type: ToolTypeClient, description: 'c' },
|
|
46
|
+
handler,
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
expect(schemas).toEqual([
|
|
50
|
+
{ name: 'server', type: ToolTypeClient, description: 's' },
|
|
51
|
+
{ name: 'client', type: ToolTypeClient, description: 'c' },
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('extractClientToolHandlers', () => {
|
|
56
|
+
it('builds a name-to-handler map from mixed tools', () => {
|
|
57
|
+
const handlerA = jest.fn();
|
|
58
|
+
const handlerB = jest.fn();
|
|
59
|
+
const map = extractClientToolHandlers([
|
|
60
|
+
{ name: 'ignored', type: ToolTypeClient, description: 'no handler' },
|
|
61
|
+
{
|
|
62
|
+
schema: { name: 'tool_a', type: ToolTypeClient, description: 'a' },
|
|
63
|
+
handler: handlerA,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
schema: { name: 'tool_b', type: ToolTypeClient, description: 'b' },
|
|
67
|
+
handler: handlerB,
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
expect(map.size).toBe(2);
|
|
71
|
+
expect(map.get('tool_a')).toBe(handlerA);
|
|
72
|
+
expect(map.get('tool_b')).toBe(handlerB);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|