@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.
@@ -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
+ });
@@ -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', () => {
@@ -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);
@@ -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' });
@@ -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' }] };
@@ -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(() => {
@@ -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
  });
@@ -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);
@@ -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
@@ -1983,6 +1983,7 @@ export interface UpdateIntegrationScopesRequest {
1983
1983
  */
1984
1984
  export interface SuggestRequest {
1985
1985
  query: string;
1986
+ context?: string;
1986
1987
  limit?: number;
1987
1988
  category?: string;
1988
1989
  agent?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inferencesh/sdk",
3
- "version": "0.6.13",
3
+ "version": "0.6.14",
4
4
  "description": "Official JavaScript/TypeScript SDK for inference.sh - Run AI models with a simple API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",