@inferencesh/sdk 0.6.13 → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/actions.test.js +104 -1
- package/dist/agent/api.test.js +21 -0
- package/dist/api/agents.test.js +120 -1
- package/dist/api/files.test.js +8 -0
- package/dist/api/search.test.js +11 -0
- package/dist/api/tasks.test.js +21 -0
- package/dist/http/poll.test.js +61 -0
- package/dist/http/streamable.test.js +100 -0
- package/dist/proxy/index.test.js +17 -0
- package/dist/stream.test.js +9 -0
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChatStatusBusy, ToolInvocationStatusAwaitingInput, ToolInvocationStatusInProgress, ToolTypeClient, } from '../types';
|
|
2
|
-
import { createActions } from './actions';
|
|
2
|
+
import { createActions, getClientToolHandlers } from './actions';
|
|
3
3
|
import * as agentApi from './api';
|
|
4
4
|
import { PollManager } from '../http/poll';
|
|
5
5
|
import { StreamableManager } from '../http/streamable';
|
|
@@ -230,6 +230,35 @@ describe('createActions', () => {
|
|
|
230
230
|
expect(pollInstances[0].options.pollFunction).toBeDefined();
|
|
231
231
|
expect(pollInstances[0].start).toHaveBeenCalled();
|
|
232
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
|
+
});
|
|
233
262
|
});
|
|
234
263
|
describe('stopStream', () => {
|
|
235
264
|
it('should clear the manager ref before stop so onEnd does not double-dispatch idle', async () => {
|
|
@@ -246,6 +275,19 @@ describe('createActions', () => {
|
|
|
246
275
|
// Only the explicit stopStream dispatch, not a second from onEnd
|
|
247
276
|
expect(idleDispatches).toHaveLength(1);
|
|
248
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
|
+
});
|
|
249
291
|
it('should clear the manager ref before poll stop so onStop does not double-dispatch idle', async () => {
|
|
250
292
|
const onStatusChange = jest.fn();
|
|
251
293
|
const { ctx, dispatch, setStreamManager } = createTestContext({
|
|
@@ -264,6 +306,22 @@ describe('createActions', () => {
|
|
|
264
306
|
expect(idleDispatches).toHaveLength(1);
|
|
265
307
|
expect(onStatusChange).toHaveBeenCalledWith('idle');
|
|
266
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
|
+
});
|
|
267
325
|
});
|
|
268
326
|
describe('stream and poll error callbacks', () => {
|
|
269
327
|
it('should forward stream errors to onError callback', async () => {
|
|
@@ -382,6 +440,22 @@ describe('createActions', () => {
|
|
|
382
440
|
});
|
|
383
441
|
});
|
|
384
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
|
+
});
|
|
385
459
|
it('should fetch full chat when poll status changes', async () => {
|
|
386
460
|
const { ctx: baseCtx } = createTestContext({ getStreamEnabled: () => false });
|
|
387
461
|
const { ctx } = createTestContext({
|
|
@@ -715,3 +789,32 @@ describe('createActions', () => {
|
|
|
715
789
|
});
|
|
716
790
|
});
|
|
717
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
|
+
});
|
package/dist/agent/api.test.js
CHANGED
|
@@ -125,6 +125,15 @@ describe('agent/api', () => {
|
|
|
125
125
|
const body = JSON.parse(String(runInit.body));
|
|
126
126
|
expect(body.input.attachments).toEqual([fileRecord]);
|
|
127
127
|
});
|
|
128
|
+
it('should route template agent configs to /agents/run without agent_config', async () => {
|
|
129
|
+
mockJsonResponse(runResponse);
|
|
130
|
+
await sendMessage(makeClient(), { agent: 'agent-template-1' }, 'chat-existing', 'hi');
|
|
131
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
132
|
+
const body = JSON.parse(String(init.body));
|
|
133
|
+
expect(body.agent).toBe('agent-template-1');
|
|
134
|
+
expect(body.chat_id).toBe('chat-existing');
|
|
135
|
+
expect(body).not.toHaveProperty('agent_config');
|
|
136
|
+
});
|
|
128
137
|
});
|
|
129
138
|
describe('submitToolResult', () => {
|
|
130
139
|
it('should wrap string results in { result }', async () => {
|
|
@@ -143,6 +152,10 @@ describe('agent/api', () => {
|
|
|
143
152
|
const [, init] = mockFetch.mock.calls[0];
|
|
144
153
|
expect(JSON.parse(String(init.body))).toEqual(payload);
|
|
145
154
|
});
|
|
155
|
+
it('should rethrow when the request fails', async () => {
|
|
156
|
+
mockFetch.mockRejectedValueOnce(new Error('submit failed'));
|
|
157
|
+
await expect(submitToolResult(makeClient(), 'inv-3', 'done')).rejects.toThrow('submit failed');
|
|
158
|
+
});
|
|
146
159
|
});
|
|
147
160
|
describe('fetchChat', () => {
|
|
148
161
|
it('should return chat data on success', async () => {
|
|
@@ -196,6 +209,14 @@ describe('agent/api', () => {
|
|
|
196
209
|
mockFetch.mockRejectedValueOnce(new Error('approve failed'));
|
|
197
210
|
await expect(approveTool(makeClient(), 'inv-1')).rejects.toThrow('approve failed');
|
|
198
211
|
});
|
|
212
|
+
it('should rethrow when rejectTool request fails', async () => {
|
|
213
|
+
mockFetch.mockRejectedValueOnce(new Error('reject failed'));
|
|
214
|
+
await expect(rejectTool(makeClient(), 'inv-1', 'unsafe')).rejects.toThrow('reject failed');
|
|
215
|
+
});
|
|
216
|
+
it('should rethrow when alwaysAllowTool request fails', async () => {
|
|
217
|
+
mockFetch.mockRejectedValueOnce(new Error('allow failed'));
|
|
218
|
+
await expect(alwaysAllowTool(makeClient(), 'chat-1', 'inv-1', 'browser_tool')).rejects.toThrow('allow failed');
|
|
219
|
+
});
|
|
199
220
|
});
|
|
200
221
|
describe('getChatStreamConfig', () => {
|
|
201
222
|
it('should delegate to HttpClient.getStreamableConfig for the chat stream path', () => {
|
package/dist/api/agents.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { HttpClient } from '../http/client';
|
|
2
2
|
import { StreamableManager } from '../http/streamable';
|
|
3
3
|
import { PollManager } from '../http/poll';
|
|
4
|
-
import { ChatStatusBusy, ChatStatusIdle, ToolInvocationStatusAwaitingInput, ToolTypeClient, } from '../types';
|
|
4
|
+
import { ChatStatusBusy, ChatStatusIdle, ToolInvocationStatusAwaitingInput, ToolInvocationStatusInProgress, ToolTypeClient, } from '../types';
|
|
5
5
|
import { FilesAPI } from './files';
|
|
6
6
|
import { AgentsAPI } from './agents';
|
|
7
7
|
const mockFetch = jest.fn();
|
|
@@ -97,6 +97,40 @@ describe('Agent.sendMessage (polling mode)', () => {
|
|
|
97
97
|
init.method === 'GET');
|
|
98
98
|
expect(fullChatGets).toHaveLength(2);
|
|
99
99
|
});
|
|
100
|
+
it('should dispatch onToolCall for in_progress client tool invocations', async () => {
|
|
101
|
+
const toolInvocation = {
|
|
102
|
+
id: 'tool-inv-progress',
|
|
103
|
+
type: ToolTypeClient,
|
|
104
|
+
status: ToolInvocationStatusInProgress,
|
|
105
|
+
function: { name: 'my_tool', arguments: { x: 2 } },
|
|
106
|
+
};
|
|
107
|
+
const messageWithTool = makeMessage({ tool_invocations: [toolInvocation] });
|
|
108
|
+
mockJsonResponse({
|
|
109
|
+
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
110
|
+
assistant_message: makeMessage(),
|
|
111
|
+
});
|
|
112
|
+
mockJsonResponse({ status: ChatStatusBusy });
|
|
113
|
+
mockJsonResponse({
|
|
114
|
+
id: 'chat-1',
|
|
115
|
+
status: ChatStatusBusy,
|
|
116
|
+
chat_messages: [messageWithTool],
|
|
117
|
+
});
|
|
118
|
+
mockJsonResponse({ status: ChatStatusIdle });
|
|
119
|
+
mockJsonResponse({
|
|
120
|
+
id: 'chat-1',
|
|
121
|
+
status: ChatStatusIdle,
|
|
122
|
+
chat_messages: [messageWithTool],
|
|
123
|
+
});
|
|
124
|
+
const onMessage = jest.fn();
|
|
125
|
+
const onToolCall = jest.fn();
|
|
126
|
+
await agent().sendMessage('run tool', { stream: false, onMessage, onToolCall });
|
|
127
|
+
expect(onToolCall).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(onToolCall).toHaveBeenCalledWith({
|
|
129
|
+
id: 'tool-inv-progress',
|
|
130
|
+
name: 'my_tool',
|
|
131
|
+
args: { x: 2 },
|
|
132
|
+
});
|
|
133
|
+
});
|
|
100
134
|
it('should dispatch onToolCall once per client tool invocation', async () => {
|
|
101
135
|
const toolInvocation = {
|
|
102
136
|
id: 'tool-inv-1',
|
|
@@ -188,6 +222,30 @@ describe('Agent.sendMessage (polling mode)', () => {
|
|
|
188
222
|
const output = await agent().run('no finish tool');
|
|
189
223
|
expect(output).toBeNull();
|
|
190
224
|
});
|
|
225
|
+
it('should warn on repeated poll errors without resolving sendMessage', async () => {
|
|
226
|
+
jest.useFakeTimers();
|
|
227
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
|
228
|
+
mockJsonResponse({
|
|
229
|
+
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
230
|
+
assistant_message: makeMessage(),
|
|
231
|
+
});
|
|
232
|
+
mockFetch.mockRejectedValue(new Error('status endpoint down'));
|
|
233
|
+
const sendPromise = agent().sendMessage('hello', { stream: false });
|
|
234
|
+
for (let i = 0; i < 6; i++) {
|
|
235
|
+
await Promise.resolve();
|
|
236
|
+
jest.advanceTimersByTime(20);
|
|
237
|
+
await Promise.resolve();
|
|
238
|
+
}
|
|
239
|
+
expect(warnSpy).toHaveBeenCalledWith('[Agent] Poll error:', expect.any(Error));
|
|
240
|
+
let settled = false;
|
|
241
|
+
sendPromise.then(() => {
|
|
242
|
+
settled = true;
|
|
243
|
+
});
|
|
244
|
+
await Promise.resolve();
|
|
245
|
+
expect(settled).toBe(false);
|
|
246
|
+
warnSpy.mockRestore();
|
|
247
|
+
jest.useRealTimers();
|
|
248
|
+
});
|
|
191
249
|
});
|
|
192
250
|
describe('Agent.sendMessage (streaming mode)', () => {
|
|
193
251
|
beforeEach(() => {
|
|
@@ -220,6 +278,39 @@ describe('Agent.sendMessage (streaming mode)', () => {
|
|
|
220
278
|
expect(result.userMessage).toEqual(userMessage);
|
|
221
279
|
expect(onChat).toHaveBeenCalledWith(expect.objectContaining({ id: 'chat-1', status: ChatStatusIdle }));
|
|
222
280
|
});
|
|
281
|
+
it('should dispatch onToolCall for in_progress tools from chat_messages stream events', async () => {
|
|
282
|
+
const toolInvocation = {
|
|
283
|
+
id: 'tool-inv-progress',
|
|
284
|
+
type: ToolTypeClient,
|
|
285
|
+
status: ToolInvocationStatusInProgress,
|
|
286
|
+
function: { name: 'my_tool', arguments: { x: 2 } },
|
|
287
|
+
};
|
|
288
|
+
const messageWithTool = makeMessage({ tool_invocations: [toolInvocation] });
|
|
289
|
+
mockFetch.mockImplementation((url) => {
|
|
290
|
+
if (url.includes('/agents/run')) {
|
|
291
|
+
return Promise.resolve({
|
|
292
|
+
ok: true,
|
|
293
|
+
status: 200,
|
|
294
|
+
text: () => Promise.resolve(JSON.stringify({
|
|
295
|
+
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
296
|
+
assistant_message: makeMessage(),
|
|
297
|
+
})),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return Promise.resolve(mockNdjsonStream([
|
|
301
|
+
`${JSON.stringify({ event: 'chat_messages', data: messageWithTool })}\n`,
|
|
302
|
+
`${JSON.stringify({ event: 'chats', data: { id: 'chat-1', status: ChatStatusIdle } })}\n`,
|
|
303
|
+
]));
|
|
304
|
+
});
|
|
305
|
+
const onToolCall = jest.fn();
|
|
306
|
+
await streamingAgent().sendMessage('run tool', { onToolCall });
|
|
307
|
+
expect(onToolCall).toHaveBeenCalledTimes(1);
|
|
308
|
+
expect(onToolCall).toHaveBeenCalledWith({
|
|
309
|
+
id: 'tool-inv-progress',
|
|
310
|
+
name: 'my_tool',
|
|
311
|
+
args: { x: 2 },
|
|
312
|
+
});
|
|
313
|
+
});
|
|
223
314
|
it('should dispatch onToolCall from chat_messages stream events', async () => {
|
|
224
315
|
const toolInvocation = {
|
|
225
316
|
id: 'tool-inv-1',
|
|
@@ -714,6 +805,20 @@ describe('Agent.getChat', () => {
|
|
|
714
805
|
expect(url).toContain('/chats/chat-42');
|
|
715
806
|
expect(init.method).toBe('GET');
|
|
716
807
|
});
|
|
808
|
+
it('should expose currentChatId after sendMessage establishes a chat', async () => {
|
|
809
|
+
const agentInstance = agent();
|
|
810
|
+
mockJsonResponse({
|
|
811
|
+
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
812
|
+
assistant_message: makeMessage(),
|
|
813
|
+
});
|
|
814
|
+
mockJsonResponse({ status: ChatStatusBusy });
|
|
815
|
+
mockJsonResponse({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] });
|
|
816
|
+
mockJsonResponse({ status: ChatStatusIdle });
|
|
817
|
+
mockJsonResponse({ id: 'chat-1', status: ChatStatusIdle, chat_messages: [] });
|
|
818
|
+
expect(agentInstance.currentChatId).toBeNull();
|
|
819
|
+
await agentInstance.sendMessage('hello', { stream: false });
|
|
820
|
+
expect(agentInstance.currentChatId).toBe('chat-1');
|
|
821
|
+
});
|
|
717
822
|
it('should GET /chats/{id} with established chat id when chatId is omitted', async () => {
|
|
718
823
|
const agentInstance = agent();
|
|
719
824
|
mockJsonResponse({
|
|
@@ -797,6 +902,20 @@ describe('AgentsAPI (template CRUD)', () => {
|
|
|
797
902
|
expect(url).toContain('/agents/internal-tools');
|
|
798
903
|
expect(init.method).toBe('GET');
|
|
799
904
|
});
|
|
905
|
+
it('should GET /agents/{id}/card for getA2ACard()', async () => {
|
|
906
|
+
const card = {
|
|
907
|
+
name: 'support-bot',
|
|
908
|
+
description: 'Customer support agent',
|
|
909
|
+
url: 'https://api.example.com/agents/support-bot',
|
|
910
|
+
version: '1.0.0',
|
|
911
|
+
};
|
|
912
|
+
mockJsonResponse(card);
|
|
913
|
+
const result = await api().getA2ACard('agent-1');
|
|
914
|
+
expect(result).toEqual(card);
|
|
915
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
916
|
+
expect(url).toContain('/agents/agent-1/card');
|
|
917
|
+
expect(init.method).toBe('GET');
|
|
918
|
+
});
|
|
800
919
|
it('should POST team_id for transferOwnership()', async () => {
|
|
801
920
|
const agent = { id: 'agent-1' };
|
|
802
921
|
mockJsonResponse(agent);
|
package/dist/api/files.test.js
CHANGED
|
@@ -41,6 +41,14 @@ describe('FilesAPI', () => {
|
|
|
41
41
|
expect(init.method).toBe('DELETE');
|
|
42
42
|
});
|
|
43
43
|
describe('processInput', () => {
|
|
44
|
+
it('should return falsy inputs unchanged without uploading', async () => {
|
|
45
|
+
await expect(api().processInput(null)).resolves.toBeNull();
|
|
46
|
+
await expect(api().processInput(undefined)).resolves.toBeUndefined();
|
|
47
|
+
await expect(api().processInput(0)).resolves.toBe(0);
|
|
48
|
+
await expect(api().processInput(false)).resolves.toBe(false);
|
|
49
|
+
await expect(api().processInput('')).resolves.toBe('');
|
|
50
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
44
52
|
it('should not treat short plain strings as base64 file uploads', async () => {
|
|
45
53
|
const result = await api().processInput({ key: 'key1', note: 'hello' });
|
|
46
54
|
expect(result).toEqual({ key: 'key1', note: 'hello' });
|
package/dist/api/search.test.js
CHANGED
|
@@ -25,6 +25,17 @@ describe('SearchAPI', () => {
|
|
|
25
25
|
expect(init.method).toBe('POST');
|
|
26
26
|
expect(JSON.parse(init.body)).toEqual(payload);
|
|
27
27
|
});
|
|
28
|
+
it('should forward conversation context in suggest() body', async () => {
|
|
29
|
+
const payload = {
|
|
30
|
+
query: 'flux',
|
|
31
|
+
context: 'previous conversation about image generation',
|
|
32
|
+
limit: 5,
|
|
33
|
+
};
|
|
34
|
+
mockJsonResponse({ results: [] });
|
|
35
|
+
await api().suggest(payload);
|
|
36
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
37
|
+
expect(JSON.parse(init.body)).toEqual(payload);
|
|
38
|
+
});
|
|
28
39
|
it('should POST /search for search()', async () => {
|
|
29
40
|
const payload = { q: 'claude', type: 'apps', limit: 5 };
|
|
30
41
|
const response = { hits: [{ id: 'app-1' }] };
|
package/dist/api/tasks.test.js
CHANGED
|
@@ -217,6 +217,27 @@ describe('TasksAPI.run (streaming mode)', () => {
|
|
|
217
217
|
expect(result.status).toBe(TaskStatusCompleted);
|
|
218
218
|
expect(onPartialUpdate).toHaveBeenCalledWith(expect.objectContaining({ id: 'task-1', status: TaskStatusCompleted }), ['status']);
|
|
219
219
|
});
|
|
220
|
+
it('should preserve session_id across partial stream updates that omit it', async () => {
|
|
221
|
+
setupStreamMocks([
|
|
222
|
+
`${JSON.stringify({
|
|
223
|
+
data: { status: TaskStatusRunning, id: 'task-1' },
|
|
224
|
+
fields: ['status'],
|
|
225
|
+
})}\n`,
|
|
226
|
+
`${JSON.stringify({
|
|
227
|
+
data: { status: TaskStatusCompleted, id: 'task-1', output: { ok: true } },
|
|
228
|
+
fields: ['status', 'output'],
|
|
229
|
+
})}\n`,
|
|
230
|
+
], makeTask({ session_id: 'sess-persist' }));
|
|
231
|
+
const onPartialUpdate = jest.fn();
|
|
232
|
+
const result = await api().run({ app: 'test-app', input: {} }, {}, { wait: true, onPartialUpdate });
|
|
233
|
+
expect(result.session_id).toBe('sess-persist');
|
|
234
|
+
expect(onPartialUpdate).toHaveBeenCalledWith(expect.objectContaining({ session_id: 'sess-persist', status: TaskStatusRunning }), ['status']);
|
|
235
|
+
expect(onPartialUpdate).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
236
|
+
session_id: 'sess-persist',
|
|
237
|
+
status: TaskStatusCompleted,
|
|
238
|
+
output: { ok: true },
|
|
239
|
+
}), ['status', 'output']);
|
|
240
|
+
});
|
|
220
241
|
});
|
|
221
242
|
describe('TasksAPI.run (HTTP contract)', () => {
|
|
222
243
|
beforeEach(() => {
|
package/dist/http/poll.test.js
CHANGED
|
@@ -82,4 +82,65 @@ describe('PollManager', () => {
|
|
|
82
82
|
await Promise.resolve();
|
|
83
83
|
expect(onData.mock.calls.length).toBe(callsBefore);
|
|
84
84
|
});
|
|
85
|
+
it('should not run overlapping polls when pollFunction is still in flight', async () => {
|
|
86
|
+
let resolvePoll = () => { };
|
|
87
|
+
const pollFunction = jest.fn().mockImplementation(() => new Promise((resolve) => {
|
|
88
|
+
resolvePoll = resolve;
|
|
89
|
+
}));
|
|
90
|
+
const manager = new PollManager({
|
|
91
|
+
pollFunction,
|
|
92
|
+
intervalMs: 100,
|
|
93
|
+
});
|
|
94
|
+
manager.start();
|
|
95
|
+
await Promise.resolve();
|
|
96
|
+
expect(pollFunction).toHaveBeenCalledTimes(1);
|
|
97
|
+
jest.advanceTimersByTime(100);
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
expect(pollFunction).toHaveBeenCalledTimes(1);
|
|
100
|
+
resolvePoll({ status: 'ok' });
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
jest.advanceTimersByTime(100);
|
|
103
|
+
await Promise.resolve();
|
|
104
|
+
expect(pollFunction).toHaveBeenCalledTimes(2);
|
|
105
|
+
manager.stop();
|
|
106
|
+
});
|
|
107
|
+
it('should ignore start() when polling is already active', async () => {
|
|
108
|
+
const onStart = jest.fn();
|
|
109
|
+
const pollFunction = jest.fn().mockResolvedValue({});
|
|
110
|
+
const manager = new PollManager({ pollFunction, onStart });
|
|
111
|
+
manager.start();
|
|
112
|
+
manager.start();
|
|
113
|
+
expect(onStart).toHaveBeenCalledTimes(1);
|
|
114
|
+
manager.stop();
|
|
115
|
+
});
|
|
116
|
+
it('should reset consecutive error count after a successful poll', async () => {
|
|
117
|
+
const onError = jest.fn();
|
|
118
|
+
const onStop = jest.fn();
|
|
119
|
+
const pollFunction = jest
|
|
120
|
+
.fn()
|
|
121
|
+
.mockRejectedValueOnce(new Error('fail 1'))
|
|
122
|
+
.mockResolvedValueOnce({ status: 'ok' })
|
|
123
|
+
.mockRejectedValueOnce(new Error('fail 2'))
|
|
124
|
+
.mockRejectedValueOnce(new Error('fail 3'));
|
|
125
|
+
const manager = new PollManager({
|
|
126
|
+
pollFunction,
|
|
127
|
+
intervalMs: 100,
|
|
128
|
+
maxRetries: 2,
|
|
129
|
+
onError,
|
|
130
|
+
onStop,
|
|
131
|
+
});
|
|
132
|
+
manager.start();
|
|
133
|
+
await Promise.resolve();
|
|
134
|
+
jest.advanceTimersByTime(100);
|
|
135
|
+
await Promise.resolve();
|
|
136
|
+
expect(onStop).not.toHaveBeenCalled();
|
|
137
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
138
|
+
jest.advanceTimersByTime(100);
|
|
139
|
+
await Promise.resolve();
|
|
140
|
+
jest.advanceTimersByTime(100);
|
|
141
|
+
await Promise.resolve();
|
|
142
|
+
expect(onStop).toHaveBeenCalled();
|
|
143
|
+
expect(onError).toHaveBeenCalledTimes(3);
|
|
144
|
+
manager.stop();
|
|
145
|
+
});
|
|
85
146
|
});
|
|
@@ -152,6 +152,18 @@ describe('streamable', () => {
|
|
|
152
152
|
}
|
|
153
153
|
}).rejects.toThrow('HTTP 401: Unauthorized');
|
|
154
154
|
});
|
|
155
|
+
it('should throw when the response has no body', async () => {
|
|
156
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
157
|
+
ok: true,
|
|
158
|
+
status: 200,
|
|
159
|
+
body: null,
|
|
160
|
+
});
|
|
161
|
+
await expect(async () => {
|
|
162
|
+
for await (const _ of streamable('http://test.com/stream')) {
|
|
163
|
+
// consume
|
|
164
|
+
}
|
|
165
|
+
}).rejects.toThrow('No response body');
|
|
166
|
+
});
|
|
155
167
|
// Edge cases for chunking/buffering
|
|
156
168
|
describe('chunking edge cases', () => {
|
|
157
169
|
it('should handle message split across many chunks', async () => {
|
|
@@ -379,6 +391,68 @@ describe('StreamableManager', () => {
|
|
|
379
391
|
await manager.start();
|
|
380
392
|
expect(events).toEqual(['start', 'data', 'end']);
|
|
381
393
|
});
|
|
394
|
+
it('should dispatch typed events to addEventListener subscribers', async () => {
|
|
395
|
+
global.fetch = mockFetch([
|
|
396
|
+
'{"event":"chats","data":{"id":"chat-1","status":"busy"}}\n',
|
|
397
|
+
'{"event":"chat_messages","data":{"id":"msg-1","content":"hi"}}\n',
|
|
398
|
+
]);
|
|
399
|
+
const onChat = jest.fn();
|
|
400
|
+
const onMessage = jest.fn();
|
|
401
|
+
const manager = new StreamableManager({
|
|
402
|
+
url: 'http://test.com/stream',
|
|
403
|
+
onData: jest.fn(),
|
|
404
|
+
});
|
|
405
|
+
manager.addEventListener('chats', onChat);
|
|
406
|
+
manager.addEventListener('chat_messages', onMessage);
|
|
407
|
+
await manager.start();
|
|
408
|
+
expect(onChat).toHaveBeenCalledWith({ id: 'chat-1', status: 'busy' });
|
|
409
|
+
expect(onMessage).toHaveBeenCalledWith({ id: 'msg-1', content: 'hi' });
|
|
410
|
+
});
|
|
411
|
+
it('should stop dispatching to an unsubscribed listener', async () => {
|
|
412
|
+
global.fetch = mockFetch([
|
|
413
|
+
'{"event":"chats","data":{"id":"chat-1"}}\n',
|
|
414
|
+
]);
|
|
415
|
+
const removedListener = jest.fn();
|
|
416
|
+
const activeListener = jest.fn();
|
|
417
|
+
const manager = new StreamableManager({
|
|
418
|
+
url: 'http://test.com/stream',
|
|
419
|
+
});
|
|
420
|
+
const unsubscribe = manager.addEventListener('chats', removedListener);
|
|
421
|
+
manager.addEventListener('chats', activeListener);
|
|
422
|
+
unsubscribe();
|
|
423
|
+
await manager.start();
|
|
424
|
+
expect(removedListener).not.toHaveBeenCalled();
|
|
425
|
+
expect(activeListener).toHaveBeenCalledWith({ id: 'chat-1' });
|
|
426
|
+
});
|
|
427
|
+
it('should ignore start() when a stream is already running', async () => {
|
|
428
|
+
let readCount = 0;
|
|
429
|
+
const mockReader = {
|
|
430
|
+
read: jest.fn().mockImplementation(async () => {
|
|
431
|
+
readCount++;
|
|
432
|
+
if (readCount > 3) {
|
|
433
|
+
return { done: true, value: undefined };
|
|
434
|
+
}
|
|
435
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
436
|
+
return { done: false, value: new TextEncoder().encode('{"id":1}\n') };
|
|
437
|
+
}),
|
|
438
|
+
releaseLock: jest.fn(),
|
|
439
|
+
};
|
|
440
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
441
|
+
ok: true,
|
|
442
|
+
body: { getReader: () => mockReader },
|
|
443
|
+
});
|
|
444
|
+
const onStart = jest.fn();
|
|
445
|
+
const manager = new StreamableManager({
|
|
446
|
+
url: 'http://test.com/stream',
|
|
447
|
+
onStart,
|
|
448
|
+
onData: () => { },
|
|
449
|
+
});
|
|
450
|
+
const firstStart = manager.start();
|
|
451
|
+
await manager.start();
|
|
452
|
+
await firstStart;
|
|
453
|
+
expect(onStart).toHaveBeenCalledTimes(1);
|
|
454
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
455
|
+
});
|
|
382
456
|
it('should handle stop()', async () => {
|
|
383
457
|
let readCount = 0;
|
|
384
458
|
const mockReader = {
|
|
@@ -407,4 +481,30 @@ describe('StreamableManager', () => {
|
|
|
407
481
|
// Should have stopped before reading all 10
|
|
408
482
|
expect(readCount).toBeLessThan(10);
|
|
409
483
|
});
|
|
484
|
+
it('should call onError when the stream request fails', async () => {
|
|
485
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
486
|
+
ok: false,
|
|
487
|
+
status: 503,
|
|
488
|
+
text: async () => 'Service unavailable',
|
|
489
|
+
});
|
|
490
|
+
const onError = jest.fn();
|
|
491
|
+
const manager = new StreamableManager({
|
|
492
|
+
url: 'http://test.com/stream',
|
|
493
|
+
onError,
|
|
494
|
+
});
|
|
495
|
+
await manager.start();
|
|
496
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'HTTP 503: Service unavailable' }));
|
|
497
|
+
});
|
|
498
|
+
it('should route partial updates to onData when onPartialData is not provided', async () => {
|
|
499
|
+
global.fetch = mockFetch([
|
|
500
|
+
'{"data":{"id":1,"status":"busy"},"fields":["status"]}\n',
|
|
501
|
+
]);
|
|
502
|
+
const onData = jest.fn();
|
|
503
|
+
const manager = new StreamableManager({
|
|
504
|
+
url: 'http://test.com/stream',
|
|
505
|
+
onData,
|
|
506
|
+
});
|
|
507
|
+
await manager.start();
|
|
508
|
+
expect(onData).toHaveBeenCalledWith({ id: 1, status: 'busy' });
|
|
509
|
+
});
|
|
410
510
|
});
|
package/dist/proxy/index.test.js
CHANGED
|
@@ -106,6 +106,23 @@ describe('processProxyRequest', () => {
|
|
|
106
106
|
}),
|
|
107
107
|
}));
|
|
108
108
|
});
|
|
109
|
+
it('should prefer client Authorization header over env API key', async () => {
|
|
110
|
+
const target = 'https://api.inference.sh/v1/tasks/1';
|
|
111
|
+
await processProxyRequest(createTestAdapter({
|
|
112
|
+
header: (name) => {
|
|
113
|
+
if (name === INF_TARGET_HEADER)
|
|
114
|
+
return target;
|
|
115
|
+
if (name === 'authorization')
|
|
116
|
+
return 'Bearer client-session-token';
|
|
117
|
+
return undefined;
|
|
118
|
+
},
|
|
119
|
+
}));
|
|
120
|
+
expect(global.fetch).toHaveBeenCalledWith(target, expect.objectContaining({
|
|
121
|
+
headers: expect.objectContaining({
|
|
122
|
+
authorization: 'Bearer client-session-token',
|
|
123
|
+
}),
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
109
126
|
it('should use query param fallback when header is missing (SSE clients)', async () => {
|
|
110
127
|
const target = 'https://api.inference.sh/v1/stream';
|
|
111
128
|
const encoded = encodeURIComponent(target);
|
package/dist/stream.test.js
CHANGED
|
@@ -34,6 +34,15 @@ describe('StreamManager', () => {
|
|
|
34
34
|
await manager.connect();
|
|
35
35
|
expect(onStart).toHaveBeenCalled();
|
|
36
36
|
});
|
|
37
|
+
it('should not call onStart when createEventSource returns null', async () => {
|
|
38
|
+
const onStart = jest.fn();
|
|
39
|
+
const manager = new StreamManager({
|
|
40
|
+
createEventSource: async () => null,
|
|
41
|
+
onStart,
|
|
42
|
+
});
|
|
43
|
+
await manager.connect();
|
|
44
|
+
expect(onStart).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
37
46
|
it('should parse JSON messages and call onData', async () => {
|
|
38
47
|
const onData = jest.fn();
|
|
39
48
|
const manager = new StreamManager({
|
package/dist/types.d.ts
CHANGED