@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,208 @@
|
|
|
1
|
+
import { HttpClient } from '../http/client';
|
|
2
|
+
import { FilesAPI } from '../api/files';
|
|
3
|
+
import { sendAdHocMessage, sendTemplateMessage, sendMessage, submitToolResult, approveTool, rejectTool, alwaysAllowTool, fetchChat, stopChat, 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 return null when the API response omits messages', async () => {
|
|
34
|
+
mockJsonResponse({});
|
|
35
|
+
const result = await sendAdHocMessage(makeClient(), adHocConfig, null, 'hello');
|
|
36
|
+
expect(result).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
it('should strip client tool handlers from the agents/run request body', async () => {
|
|
39
|
+
mockJsonResponse(runResponse);
|
|
40
|
+
const handler = jest.fn().mockReturnValue('ok');
|
|
41
|
+
await sendAdHocMessage(makeClient(), {
|
|
42
|
+
...adHocConfig,
|
|
43
|
+
tools: [
|
|
44
|
+
{
|
|
45
|
+
schema: {
|
|
46
|
+
name: 'browser_tool',
|
|
47
|
+
type: ToolTypeClient,
|
|
48
|
+
description: 'runs in browser',
|
|
49
|
+
},
|
|
50
|
+
handler,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
}, null, 'hello');
|
|
54
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
55
|
+
const body = JSON.parse(String(init.body));
|
|
56
|
+
expect(body.agent_config.tools[0]).toEqual({
|
|
57
|
+
name: 'browser_tool',
|
|
58
|
+
type: ToolTypeClient,
|
|
59
|
+
description: 'runs in browser',
|
|
60
|
+
});
|
|
61
|
+
expect(body.agent_config.tools[0]).not.toHaveProperty('handler');
|
|
62
|
+
expect(handler).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('sendTemplateMessage', () => {
|
|
66
|
+
it('should return null when assistant_message is missing', async () => {
|
|
67
|
+
mockJsonResponse({
|
|
68
|
+
user_message: { id: 'u1', chat_id: 'chat-1', role: 'user' },
|
|
69
|
+
});
|
|
70
|
+
const result = await sendTemplateMessage(makeClient(), { agent: 'agent-1' }, null, 'hello');
|
|
71
|
+
expect(result).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
it('should omit empty agent field for existing chats', async () => {
|
|
74
|
+
mockJsonResponse(runResponse);
|
|
75
|
+
await sendTemplateMessage(makeClient(), { agent: '' }, 'chat-existing', 'hi');
|
|
76
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
77
|
+
const body = JSON.parse(String(init.body));
|
|
78
|
+
expect(body.chat_id).toBe('chat-existing');
|
|
79
|
+
expect(body).not.toHaveProperty('agent');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('sendMessage', () => {
|
|
83
|
+
it('should pass FileRef attachments without uploading', async () => {
|
|
84
|
+
mockJsonResponse(runResponse);
|
|
85
|
+
const fileRef = {
|
|
86
|
+
id: 'f1',
|
|
87
|
+
uri: 'inf://files/abc',
|
|
88
|
+
filename: 'image.png',
|
|
89
|
+
content_type: 'image/png',
|
|
90
|
+
};
|
|
91
|
+
await sendMessage(makeClient(), adHocConfig, null, 'see image', [fileRef]);
|
|
92
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
93
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
94
|
+
const body = JSON.parse(String(init.body));
|
|
95
|
+
expect(body.input.attachments).toEqual([fileRef]);
|
|
96
|
+
});
|
|
97
|
+
it('should omit attachments when every file upload fails', async () => {
|
|
98
|
+
const client = makeClient();
|
|
99
|
+
const uploadSpy = jest
|
|
100
|
+
.spyOn(client.files, 'upload')
|
|
101
|
+
.mockRejectedValue(new Error('upload failed'));
|
|
102
|
+
mockJsonResponse(runResponse);
|
|
103
|
+
const file = new File(['data'], 'doc.txt', { type: 'text/plain' });
|
|
104
|
+
await sendMessage(client, adHocConfig, null, 'with file', [file]);
|
|
105
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
106
|
+
const body = JSON.parse(String(init.body));
|
|
107
|
+
expect(body.input.attachments).toBeUndefined();
|
|
108
|
+
uploadSpy.mockRestore();
|
|
109
|
+
});
|
|
110
|
+
it('should upload File inputs before sending', async () => {
|
|
111
|
+
const fileRecord = {
|
|
112
|
+
id: 'file-1',
|
|
113
|
+
uri: 'inf://files/uploaded',
|
|
114
|
+
filename: 'hello.txt',
|
|
115
|
+
upload_url: 'https://upload.example/put',
|
|
116
|
+
content_type: 'text/plain',
|
|
117
|
+
};
|
|
118
|
+
mockJsonResponse([fileRecord]);
|
|
119
|
+
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
|
120
|
+
mockJsonResponse(runResponse);
|
|
121
|
+
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });
|
|
122
|
+
await sendMessage(makeClient(), adHocConfig, null, 'with file', [file]);
|
|
123
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
124
|
+
const [, runInit] = mockFetch.mock.calls[2];
|
|
125
|
+
const body = JSON.parse(String(runInit.body));
|
|
126
|
+
expect(body.input.attachments).toEqual([fileRecord]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe('submitToolResult', () => {
|
|
130
|
+
it('should wrap string results in { result }', async () => {
|
|
131
|
+
mockJsonResponse(null);
|
|
132
|
+
await submitToolResult(makeClient(), 'inv-1', 'done');
|
|
133
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
134
|
+
expect(url).toContain('/tools/inv-1');
|
|
135
|
+
expect(JSON.parse(String(init.body))).toEqual({ result: 'done' });
|
|
136
|
+
});
|
|
137
|
+
it('should pass structured action objects through unchanged', async () => {
|
|
138
|
+
mockJsonResponse(null);
|
|
139
|
+
const payload = {
|
|
140
|
+
action: { type: 'approve', payload: { ok: true } },
|
|
141
|
+
};
|
|
142
|
+
await submitToolResult(makeClient(), 'inv-2', payload);
|
|
143
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
144
|
+
expect(JSON.parse(String(init.body))).toEqual(payload);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe('fetchChat', () => {
|
|
148
|
+
it('should return chat data on success', async () => {
|
|
149
|
+
const chat = { id: 'chat-1', status: 'idle' };
|
|
150
|
+
mockJsonResponse(chat);
|
|
151
|
+
const result = await fetchChat(makeClient(), 'chat-1');
|
|
152
|
+
expect(result).toEqual(chat);
|
|
153
|
+
});
|
|
154
|
+
it('should return null and not throw when the request fails', async () => {
|
|
155
|
+
mockFetch.mockRejectedValueOnce(new Error('network error'));
|
|
156
|
+
const result = await fetchChat(makeClient(), 'chat-1');
|
|
157
|
+
expect(result).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
describe('stopChat', () => {
|
|
161
|
+
it('should POST to /chats/{id}/stop', async () => {
|
|
162
|
+
mockJsonResponse(null);
|
|
163
|
+
await stopChat(makeClient(), 'chat-1');
|
|
164
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
165
|
+
expect(url).toContain('/chats/chat-1/stop');
|
|
166
|
+
expect(init.method).toBe('POST');
|
|
167
|
+
});
|
|
168
|
+
it('should swallow errors without throwing', async () => {
|
|
169
|
+
mockFetch.mockRejectedValueOnce(new Error('stop failed'));
|
|
170
|
+
await expect(stopChat(makeClient(), 'chat-1')).resolves.toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
describe('HIL tool approval', () => {
|
|
174
|
+
it('approveTool should POST to /tools/{id}/invoke', async () => {
|
|
175
|
+
mockJsonResponse(null);
|
|
176
|
+
await approveTool(makeClient(), 'inv-approve');
|
|
177
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
178
|
+
expect(url).toContain('/tools/inv-approve/invoke');
|
|
179
|
+
expect(init.method).toBe('POST');
|
|
180
|
+
});
|
|
181
|
+
it('rejectTool should POST reason to /tools/{id}/reject', async () => {
|
|
182
|
+
mockJsonResponse(null);
|
|
183
|
+
await rejectTool(makeClient(), 'inv-reject', 'not safe');
|
|
184
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
185
|
+
expect(url).toContain('/tools/inv-reject/reject');
|
|
186
|
+
expect(JSON.parse(String(init.body))).toEqual({ reason: 'not safe' });
|
|
187
|
+
});
|
|
188
|
+
it('alwaysAllowTool should POST tool_name to the chat tools endpoint', async () => {
|
|
189
|
+
mockJsonResponse(null);
|
|
190
|
+
await alwaysAllowTool(makeClient(), 'chat-1', 'inv-allow', 'browser_tool');
|
|
191
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
192
|
+
expect(url).toContain('/chats/chat-1/tools/inv-allow/always-allow');
|
|
193
|
+
expect(JSON.parse(String(init.body))).toEqual({ tool_name: 'browser_tool' });
|
|
194
|
+
});
|
|
195
|
+
it('should rethrow when approveTool request fails', async () => {
|
|
196
|
+
mockFetch.mockRejectedValueOnce(new Error('approve failed'));
|
|
197
|
+
await expect(approveTool(makeClient(), 'inv-1')).rejects.toThrow('approve failed');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
describe('getChatStreamConfig', () => {
|
|
201
|
+
it('should delegate to HttpClient.getStreamableConfig for the chat stream path', () => {
|
|
202
|
+
const client = makeClient();
|
|
203
|
+
const config = getChatStreamConfig(client, 'chat-xyz');
|
|
204
|
+
expect(config.url).toContain('/chats/chat-xyz/stream');
|
|
205
|
+
expect(config.headers).toEqual(expect.objectContaining({ Authorization: expect.stringContaining('Bearer') }));
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -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
|
+
});
|
package/dist/api/agents.test.js
CHANGED
|
@@ -20,6 +20,23 @@ function makeMessage(overrides = {}) {
|
|
|
20
20
|
...overrides,
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
+
function mockNdjsonStream(chunks) {
|
|
24
|
+
let chunkIndex = 0;
|
|
25
|
+
const mockReader = {
|
|
26
|
+
read: jest.fn().mockImplementation(async () => {
|
|
27
|
+
if (chunkIndex >= chunks.length) {
|
|
28
|
+
return { done: true, value: undefined };
|
|
29
|
+
}
|
|
30
|
+
return { done: false, value: new TextEncoder().encode(chunks[chunkIndex++]) };
|
|
31
|
+
}),
|
|
32
|
+
releaseLock: jest.fn(),
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
status: 200,
|
|
37
|
+
body: { getReader: () => mockReader },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
23
40
|
describe('Agent.sendMessage (polling mode)', () => {
|
|
24
41
|
beforeEach(() => {
|
|
25
42
|
jest.clearAllMocks();
|
|
@@ -36,18 +53,15 @@ describe('Agent.sendMessage (polling mode)', () => {
|
|
|
36
53
|
const userMessage = makeMessage({ id: 'user-1', role: 'user' });
|
|
37
54
|
const assistantMessage = makeMessage({ id: 'asst-1' });
|
|
38
55
|
mockJsonResponse({
|
|
39
|
-
|
|
40
|
-
data: { user_message: userMessage, assistant_message: assistantMessage },
|
|
56
|
+
user_message: userMessage, assistant_message: assistantMessage,
|
|
41
57
|
});
|
|
42
|
-
mockJsonResponse({
|
|
58
|
+
mockJsonResponse({ status: ChatStatusBusy });
|
|
43
59
|
mockJsonResponse({
|
|
44
|
-
|
|
45
|
-
data: { id: 'chat-1', status: ChatStatusBusy, chat_messages: [] },
|
|
60
|
+
id: 'chat-1', status: ChatStatusBusy, chat_messages: [],
|
|
46
61
|
});
|
|
47
|
-
mockJsonResponse({
|
|
62
|
+
mockJsonResponse({ status: ChatStatusIdle });
|
|
48
63
|
mockJsonResponse({
|
|
49
|
-
|
|
50
|
-
data: { id: 'chat-1', status: ChatStatusIdle, chat_messages: [] },
|
|
64
|
+
id: 'chat-1', status: ChatStatusIdle, chat_messages: [],
|
|
51
65
|
});
|
|
52
66
|
const onChat = jest.fn();
|
|
53
67
|
const result = await agent().sendMessage('hello', { stream: false, onChat });
|
|
@@ -64,27 +78,20 @@ describe('Agent.sendMessage (polling mode)', () => {
|
|
|
64
78
|
};
|
|
65
79
|
const messageWithTool = makeMessage({ tool_invocations: [toolInvocation] });
|
|
66
80
|
mockJsonResponse({
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
70
|
-
assistant_message: makeMessage(),
|
|
71
|
-
},
|
|
81
|
+
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
82
|
+
assistant_message: makeMessage(),
|
|
72
83
|
});
|
|
73
|
-
mockJsonResponse({
|
|
84
|
+
mockJsonResponse({ status: ChatStatusBusy });
|
|
74
85
|
mockJsonResponse({
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
status: ChatStatusBusy,
|
|
79
|
-
chat_messages: [messageWithTool],
|
|
80
|
-
},
|
|
86
|
+
id: 'chat-1',
|
|
87
|
+
status: ChatStatusBusy,
|
|
88
|
+
chat_messages: [messageWithTool],
|
|
81
89
|
});
|
|
82
90
|
// Same status again — stub poll should not re-dispatch tool
|
|
83
|
-
mockJsonResponse({
|
|
84
|
-
mockJsonResponse({
|
|
91
|
+
mockJsonResponse({ status: ChatStatusBusy });
|
|
92
|
+
mockJsonResponse({ status: ChatStatusIdle });
|
|
85
93
|
mockJsonResponse({
|
|
86
|
-
|
|
87
|
-
data: { id: 'chat-1', status: ChatStatusIdle, chat_messages: [messageWithTool] },
|
|
94
|
+
id: 'chat-1', status: ChatStatusIdle, chat_messages: [messageWithTool],
|
|
88
95
|
});
|
|
89
96
|
const onMessage = jest.fn();
|
|
90
97
|
const onToolCall = jest.fn();
|
|
@@ -100,27 +107,237 @@ describe('Agent.sendMessage (polling mode)', () => {
|
|
|
100
107
|
const userMessage = makeMessage({ id: 'user-1', role: 'user' });
|
|
101
108
|
const assistantMessage = makeMessage();
|
|
102
109
|
mockJsonResponse({
|
|
103
|
-
|
|
104
|
-
data: { user_message: userMessage, assistant_message: assistantMessage },
|
|
110
|
+
user_message: userMessage, assistant_message: assistantMessage,
|
|
105
111
|
});
|
|
106
|
-
mockJsonResponse({
|
|
112
|
+
mockJsonResponse({ status: ChatStatusBusy });
|
|
107
113
|
mockJsonResponse({
|
|
108
|
-
|
|
109
|
-
data: { id: 'chat-1', status: ChatStatusBusy },
|
|
114
|
+
id: 'chat-1', status: ChatStatusBusy,
|
|
110
115
|
});
|
|
111
|
-
mockJsonResponse({
|
|
116
|
+
mockJsonResponse({ status: ChatStatusIdle });
|
|
112
117
|
mockJsonResponse({
|
|
113
|
-
|
|
114
|
-
data: { id: 'chat-1', status: ChatStatusIdle },
|
|
118
|
+
id: 'chat-1', status: ChatStatusIdle,
|
|
115
119
|
});
|
|
116
120
|
mockJsonResponse({
|
|
117
|
-
|
|
118
|
-
data: { id: 'chat-1', status: ChatStatusIdle, output: { answer: 42 } },
|
|
121
|
+
id: 'chat-1', status: ChatStatusIdle, output: { answer: 42 },
|
|
119
122
|
});
|
|
120
123
|
const output = await agent().run('compute');
|
|
121
124
|
expect(output).toEqual({ answer: 42 });
|
|
122
125
|
});
|
|
123
126
|
});
|
|
127
|
+
describe('Agent.sendMessage (streaming mode)', () => {
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
jest.clearAllMocks();
|
|
130
|
+
});
|
|
131
|
+
const streamingAgent = () => {
|
|
132
|
+
const http = new HttpClient({ apiKey: 'test-key', stream: true });
|
|
133
|
+
return new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
|
|
134
|
+
};
|
|
135
|
+
it('should wait until chat is idle via typed stream events', async () => {
|
|
136
|
+
const userMessage = makeMessage({ id: 'user-1', role: 'user' });
|
|
137
|
+
const assistantMessage = makeMessage({ id: 'asst-1' });
|
|
138
|
+
mockFetch.mockImplementation((url) => {
|
|
139
|
+
if (url.includes('/agents/run')) {
|
|
140
|
+
return Promise.resolve({
|
|
141
|
+
ok: true,
|
|
142
|
+
status: 200,
|
|
143
|
+
text: () => Promise.resolve(JSON.stringify({
|
|
144
|
+
user_message: userMessage, assistant_message: assistantMessage,
|
|
145
|
+
})),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return Promise.resolve(mockNdjsonStream([
|
|
149
|
+
`${JSON.stringify({ event: 'chats', data: { id: 'chat-1', status: ChatStatusBusy } })}\n`,
|
|
150
|
+
`${JSON.stringify({ event: 'chats', data: { id: 'chat-1', status: ChatStatusIdle } })}\n`,
|
|
151
|
+
]));
|
|
152
|
+
});
|
|
153
|
+
const onChat = jest.fn();
|
|
154
|
+
const result = await streamingAgent().sendMessage('hello', { onChat });
|
|
155
|
+
expect(result.userMessage).toEqual(userMessage);
|
|
156
|
+
expect(onChat).toHaveBeenCalledWith(expect.objectContaining({ id: 'chat-1', status: ChatStatusIdle }));
|
|
157
|
+
});
|
|
158
|
+
it('should dispatch onToolCall from chat_messages stream events', async () => {
|
|
159
|
+
const toolInvocation = {
|
|
160
|
+
id: 'tool-inv-1',
|
|
161
|
+
type: ToolTypeClient,
|
|
162
|
+
status: ToolInvocationStatusAwaitingInput,
|
|
163
|
+
function: { name: 'my_tool', arguments: { x: 1 } },
|
|
164
|
+
};
|
|
165
|
+
const messageWithTool = makeMessage({ tool_invocations: [toolInvocation] });
|
|
166
|
+
mockFetch.mockImplementation((url) => {
|
|
167
|
+
if (url.includes('/agents/run')) {
|
|
168
|
+
return Promise.resolve({
|
|
169
|
+
ok: true,
|
|
170
|
+
status: 200,
|
|
171
|
+
text: () => Promise.resolve(JSON.stringify({
|
|
172
|
+
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
173
|
+
assistant_message: makeMessage(),
|
|
174
|
+
})),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return Promise.resolve(mockNdjsonStream([
|
|
178
|
+
`${JSON.stringify({ event: 'chat_messages', data: messageWithTool })}\n`,
|
|
179
|
+
`${JSON.stringify({ event: 'chats', data: { id: 'chat-1', status: ChatStatusIdle } })}\n`,
|
|
180
|
+
]));
|
|
181
|
+
});
|
|
182
|
+
const onToolCall = jest.fn();
|
|
183
|
+
await streamingAgent().sendMessage('run tool', { onToolCall });
|
|
184
|
+
expect(onToolCall).toHaveBeenCalledTimes(1);
|
|
185
|
+
expect(onToolCall).toHaveBeenCalledWith({
|
|
186
|
+
id: 'tool-inv-1',
|
|
187
|
+
name: 'my_tool',
|
|
188
|
+
args: { x: 1 },
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
it('should open the stream before POST when continuing an existing chat', async () => {
|
|
192
|
+
const http = new HttpClient({
|
|
193
|
+
apiKey: 'test-key',
|
|
194
|
+
stream: true,
|
|
195
|
+
pollIntervalMs: 20,
|
|
196
|
+
});
|
|
197
|
+
const agentInstance = new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
|
|
198
|
+
mockJsonResponse({
|
|
199
|
+
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
200
|
+
assistant_message: makeMessage(),
|
|
201
|
+
});
|
|
202
|
+
mockJsonResponse({ status: ChatStatusBusy });
|
|
203
|
+
mockJsonResponse({
|
|
204
|
+
id: 'chat-1', status: ChatStatusBusy, chat_messages: [],
|
|
205
|
+
});
|
|
206
|
+
mockJsonResponse({ status: ChatStatusIdle });
|
|
207
|
+
mockJsonResponse({
|
|
208
|
+
id: 'chat-1', status: ChatStatusIdle, chat_messages: [],
|
|
209
|
+
});
|
|
210
|
+
await agentInstance.sendMessage('first', { stream: false });
|
|
211
|
+
const callOrder = [];
|
|
212
|
+
mockFetch.mockImplementation((url) => {
|
|
213
|
+
callOrder.push(url);
|
|
214
|
+
if (url.includes('/agents/run')) {
|
|
215
|
+
return Promise.resolve({
|
|
216
|
+
ok: true,
|
|
217
|
+
status: 200,
|
|
218
|
+
text: () => Promise.resolve(JSON.stringify({
|
|
219
|
+
user_message: makeMessage({ id: 'user-2', role: 'user' }),
|
|
220
|
+
assistant_message: makeMessage({ id: 'asst-2' }),
|
|
221
|
+
})),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return Promise.resolve(mockNdjsonStream([
|
|
225
|
+
`${JSON.stringify({ event: 'chats', data: { id: 'chat-1', status: ChatStatusIdle } })}\n`,
|
|
226
|
+
]));
|
|
227
|
+
});
|
|
228
|
+
await agentInstance.sendMessage('second', { onChat: jest.fn() });
|
|
229
|
+
const streamIndex = callOrder.findIndex((u) => u.includes('/stream'));
|
|
230
|
+
const runIndex = callOrder.findIndex((u) => u.includes('/agents/run'));
|
|
231
|
+
expect(streamIndex).toBeGreaterThanOrEqual(0);
|
|
232
|
+
expect(runIndex).toBeGreaterThanOrEqual(0);
|
|
233
|
+
expect(streamIndex).toBeLessThan(runIndex);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
describe('Agent.sendMessage (file attachments)', () => {
|
|
237
|
+
beforeEach(() => {
|
|
238
|
+
jest.clearAllMocks();
|
|
239
|
+
});
|
|
240
|
+
const agent = () => {
|
|
241
|
+
const http = new HttpClient({
|
|
242
|
+
apiKey: 'test-key',
|
|
243
|
+
stream: false,
|
|
244
|
+
pollIntervalMs: 20,
|
|
245
|
+
});
|
|
246
|
+
return new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
|
|
247
|
+
};
|
|
248
|
+
it('should route image and non-image URIs into images vs files on the run request', async () => {
|
|
249
|
+
const imageFile = {
|
|
250
|
+
id: 'file-img',
|
|
251
|
+
uri: 'inf://files/img',
|
|
252
|
+
filename: 'photo.png',
|
|
253
|
+
content_type: 'image/png',
|
|
254
|
+
};
|
|
255
|
+
const docFile = {
|
|
256
|
+
id: 'file-doc',
|
|
257
|
+
uri: 'inf://files/doc',
|
|
258
|
+
filename: 'notes.pdf',
|
|
259
|
+
content_type: 'application/pdf',
|
|
260
|
+
};
|
|
261
|
+
mockJsonResponse({
|
|
262
|
+
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
263
|
+
assistant_message: makeMessage(),
|
|
264
|
+
});
|
|
265
|
+
mockJsonResponse({ status: ChatStatusBusy });
|
|
266
|
+
mockJsonResponse({
|
|
267
|
+
id: 'chat-1', status: ChatStatusBusy, chat_messages: [],
|
|
268
|
+
});
|
|
269
|
+
mockJsonResponse({ status: ChatStatusIdle });
|
|
270
|
+
mockJsonResponse({
|
|
271
|
+
id: 'chat-1', status: ChatStatusIdle, chat_messages: [],
|
|
272
|
+
});
|
|
273
|
+
await agent().sendMessage('see attachments', {
|
|
274
|
+
stream: false,
|
|
275
|
+
files: [imageFile, docFile],
|
|
276
|
+
});
|
|
277
|
+
const runCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/agents/run'));
|
|
278
|
+
const body = JSON.parse(String(runCall[1].body));
|
|
279
|
+
expect(body.input.images).toEqual(['inf://files/img']);
|
|
280
|
+
expect(body.input.files).toEqual(['inf://files/doc']);
|
|
281
|
+
expect(mockFetch.mock.calls.filter(([url]) => String(url).includes('/files')).length).toBe(0);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
describe('Agent lifecycle', () => {
|
|
285
|
+
beforeEach(() => {
|
|
286
|
+
jest.clearAllMocks();
|
|
287
|
+
});
|
|
288
|
+
const agent = () => {
|
|
289
|
+
const http = new HttpClient({
|
|
290
|
+
apiKey: 'test-key',
|
|
291
|
+
stream: false,
|
|
292
|
+
pollIntervalMs: 20,
|
|
293
|
+
});
|
|
294
|
+
return new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
|
|
295
|
+
};
|
|
296
|
+
it('stopChat should no-op when there is no active chat', async () => {
|
|
297
|
+
await agent().stopChat();
|
|
298
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
299
|
+
});
|
|
300
|
+
it('stopChat should POST to /chats/{id}/stop when a chat exists', async () => {
|
|
301
|
+
const agentInstance = agent();
|
|
302
|
+
mockJsonResponse({
|
|
303
|
+
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
304
|
+
assistant_message: makeMessage(),
|
|
305
|
+
});
|
|
306
|
+
mockJsonResponse({ status: ChatStatusBusy });
|
|
307
|
+
mockJsonResponse({
|
|
308
|
+
id: 'chat-1', status: ChatStatusBusy, chat_messages: [],
|
|
309
|
+
});
|
|
310
|
+
mockJsonResponse({ status: ChatStatusIdle });
|
|
311
|
+
mockJsonResponse({
|
|
312
|
+
id: 'chat-1', status: ChatStatusIdle, chat_messages: [],
|
|
313
|
+
});
|
|
314
|
+
await agentInstance.sendMessage('hello', { stream: false });
|
|
315
|
+
jest.clearAllMocks();
|
|
316
|
+
mockJsonResponse(null);
|
|
317
|
+
await agentInstance.stopChat();
|
|
318
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/chats/chat-1/stop'), expect.anything());
|
|
319
|
+
});
|
|
320
|
+
it('reset should clear chat state so stopChat is a no-op', async () => {
|
|
321
|
+
const agentInstance = agent();
|
|
322
|
+
mockJsonResponse({
|
|
323
|
+
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
324
|
+
assistant_message: makeMessage(),
|
|
325
|
+
});
|
|
326
|
+
mockJsonResponse({ status: ChatStatusBusy });
|
|
327
|
+
mockJsonResponse({
|
|
328
|
+
id: 'chat-1', status: ChatStatusBusy, chat_messages: [],
|
|
329
|
+
});
|
|
330
|
+
mockJsonResponse({ status: ChatStatusIdle });
|
|
331
|
+
mockJsonResponse({
|
|
332
|
+
id: 'chat-1', status: ChatStatusIdle, chat_messages: [],
|
|
333
|
+
});
|
|
334
|
+
await agentInstance.sendMessage('hello', { stream: false });
|
|
335
|
+
agentInstance.reset();
|
|
336
|
+
jest.clearAllMocks();
|
|
337
|
+
await agentInstance.stopChat();
|
|
338
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
339
|
+
});
|
|
340
|
+
});
|
|
124
341
|
describe('Agent.submitToolResult', () => {
|
|
125
342
|
beforeEach(() => {
|
|
126
343
|
jest.clearAllMocks();
|
|
@@ -128,7 +345,7 @@ describe('Agent.submitToolResult', () => {
|
|
|
128
345
|
it('should JSON-stringify structured action results', async () => {
|
|
129
346
|
const http = new HttpClient({ apiKey: 'test-key' });
|
|
130
347
|
const agentInstance = new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
|
|
131
|
-
mockJsonResponse(
|
|
348
|
+
mockJsonResponse(null);
|
|
132
349
|
const payload = {
|
|
133
350
|
action: { type: 'form_submit', payload: { field: 'value' } },
|
|
134
351
|
form_data: { field: 'value' },
|
|
@@ -139,3 +356,40 @@ describe('Agent.submitToolResult', () => {
|
|
|
139
356
|
expect(body.result).toBe(JSON.stringify(payload));
|
|
140
357
|
});
|
|
141
358
|
});
|
|
359
|
+
describe('AgentsAPI (template CRUD)', () => {
|
|
360
|
+
beforeEach(() => {
|
|
361
|
+
jest.clearAllMocks();
|
|
362
|
+
});
|
|
363
|
+
const api = () => {
|
|
364
|
+
const http = new HttpClient({ apiKey: 'test-key' });
|
|
365
|
+
return new AgentsAPI(http, new FilesAPI(http));
|
|
366
|
+
};
|
|
367
|
+
it('should GET /agents/internal-tools for getInternalTools()', async () => {
|
|
368
|
+
const tools = [{ name: 'search', description: 'Search the web' }];
|
|
369
|
+
mockJsonResponse(tools);
|
|
370
|
+
const result = await api().getInternalTools();
|
|
371
|
+
expect(result).toEqual(tools);
|
|
372
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
373
|
+
expect(url).toContain('/agents/internal-tools');
|
|
374
|
+
expect(init.method).toBe('GET');
|
|
375
|
+
});
|
|
376
|
+
it('should POST team_id for transferOwnership()', async () => {
|
|
377
|
+
const agent = { id: 'agent-1' };
|
|
378
|
+
mockJsonResponse(agent);
|
|
379
|
+
await api().transferOwnership('agent-1', 'team-42');
|
|
380
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
381
|
+
expect(url).toContain('/agents/agent-1/transfer');
|
|
382
|
+
expect(JSON.parse(init.body)).toEqual({ team_id: 'team-42' });
|
|
383
|
+
});
|
|
384
|
+
it('should POST /agents for createAgent()', async () => {
|
|
385
|
+
const payload = { name: 'support-bot', core_app: { ref: 'app/ref' } };
|
|
386
|
+
const created = { id: 'agent-new', ...payload };
|
|
387
|
+
mockJsonResponse(created);
|
|
388
|
+
const result = await api().createAgent(payload);
|
|
389
|
+
expect(result).toEqual(created);
|
|
390
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
391
|
+
expect(url).toContain('/agents');
|
|
392
|
+
expect(init.method).toBe('POST');
|
|
393
|
+
expect(JSON.parse(init.body)).toEqual(payload);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|