@inferencesh/sdk 0.6.6 → 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.
@@ -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();
@@ -121,6 +138,247 @@ describe('Agent.sendMessage (polling mode)', () => {
121
138
  expect(output).toEqual({ answer: 42 });
122
139
  });
123
140
  });
141
+ describe('Agent.sendMessage (streaming mode)', () => {
142
+ beforeEach(() => {
143
+ jest.clearAllMocks();
144
+ });
145
+ const streamingAgent = () => {
146
+ const http = new HttpClient({ apiKey: 'test-key', stream: true });
147
+ return new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
148
+ };
149
+ it('should wait until chat is idle via typed stream events', async () => {
150
+ const userMessage = makeMessage({ id: 'user-1', role: 'user' });
151
+ const assistantMessage = makeMessage({ id: 'asst-1' });
152
+ mockFetch.mockImplementation((url) => {
153
+ if (url.includes('/agents/run')) {
154
+ return Promise.resolve({
155
+ ok: true,
156
+ status: 200,
157
+ text: () => Promise.resolve(JSON.stringify({
158
+ success: true,
159
+ data: { user_message: userMessage, assistant_message: assistantMessage },
160
+ })),
161
+ });
162
+ }
163
+ return Promise.resolve(mockNdjsonStream([
164
+ `${JSON.stringify({ event: 'chats', data: { id: 'chat-1', status: ChatStatusBusy } })}\n`,
165
+ `${JSON.stringify({ event: 'chats', data: { id: 'chat-1', status: ChatStatusIdle } })}\n`,
166
+ ]));
167
+ });
168
+ const onChat = jest.fn();
169
+ const result = await streamingAgent().sendMessage('hello', { onChat });
170
+ expect(result.userMessage).toEqual(userMessage);
171
+ expect(onChat).toHaveBeenCalledWith(expect.objectContaining({ id: 'chat-1', status: ChatStatusIdle }));
172
+ });
173
+ it('should dispatch onToolCall from chat_messages stream events', async () => {
174
+ const toolInvocation = {
175
+ id: 'tool-inv-1',
176
+ type: ToolTypeClient,
177
+ status: ToolInvocationStatusAwaitingInput,
178
+ function: { name: 'my_tool', arguments: { x: 1 } },
179
+ };
180
+ const messageWithTool = makeMessage({ tool_invocations: [toolInvocation] });
181
+ mockFetch.mockImplementation((url) => {
182
+ if (url.includes('/agents/run')) {
183
+ return Promise.resolve({
184
+ ok: true,
185
+ status: 200,
186
+ text: () => Promise.resolve(JSON.stringify({
187
+ success: true,
188
+ data: {
189
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
190
+ assistant_message: makeMessage(),
191
+ },
192
+ })),
193
+ });
194
+ }
195
+ return Promise.resolve(mockNdjsonStream([
196
+ `${JSON.stringify({ event: 'chat_messages', data: messageWithTool })}\n`,
197
+ `${JSON.stringify({ event: 'chats', data: { id: 'chat-1', status: ChatStatusIdle } })}\n`,
198
+ ]));
199
+ });
200
+ const onToolCall = jest.fn();
201
+ await streamingAgent().sendMessage('run tool', { onToolCall });
202
+ expect(onToolCall).toHaveBeenCalledTimes(1);
203
+ expect(onToolCall).toHaveBeenCalledWith({
204
+ id: 'tool-inv-1',
205
+ name: 'my_tool',
206
+ args: { x: 1 },
207
+ });
208
+ });
209
+ it('should open the stream before POST when continuing an existing chat', async () => {
210
+ const http = new HttpClient({
211
+ apiKey: 'test-key',
212
+ stream: true,
213
+ pollIntervalMs: 20,
214
+ });
215
+ const agentInstance = new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
216
+ mockJsonResponse({
217
+ success: true,
218
+ data: {
219
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
220
+ assistant_message: makeMessage(),
221
+ },
222
+ });
223
+ mockJsonResponse({ success: true, data: { status: ChatStatusBusy } });
224
+ mockJsonResponse({
225
+ success: true,
226
+ data: { id: 'chat-1', status: ChatStatusBusy, chat_messages: [] },
227
+ });
228
+ mockJsonResponse({ success: true, data: { status: ChatStatusIdle } });
229
+ mockJsonResponse({
230
+ success: true,
231
+ data: { id: 'chat-1', status: ChatStatusIdle, chat_messages: [] },
232
+ });
233
+ await agentInstance.sendMessage('first', { stream: false });
234
+ const callOrder = [];
235
+ mockFetch.mockImplementation((url) => {
236
+ callOrder.push(url);
237
+ if (url.includes('/agents/run')) {
238
+ return Promise.resolve({
239
+ ok: true,
240
+ status: 200,
241
+ text: () => Promise.resolve(JSON.stringify({
242
+ success: true,
243
+ data: {
244
+ user_message: makeMessage({ id: 'user-2', role: 'user' }),
245
+ assistant_message: makeMessage({ id: 'asst-2' }),
246
+ },
247
+ })),
248
+ });
249
+ }
250
+ return Promise.resolve(mockNdjsonStream([
251
+ `${JSON.stringify({ event: 'chats', data: { id: 'chat-1', status: ChatStatusIdle } })}\n`,
252
+ ]));
253
+ });
254
+ await agentInstance.sendMessage('second', { onChat: jest.fn() });
255
+ const streamIndex = callOrder.findIndex((u) => u.includes('/stream'));
256
+ const runIndex = callOrder.findIndex((u) => u.includes('/agents/run'));
257
+ expect(streamIndex).toBeGreaterThanOrEqual(0);
258
+ expect(runIndex).toBeGreaterThanOrEqual(0);
259
+ expect(streamIndex).toBeLessThan(runIndex);
260
+ });
261
+ });
262
+ describe('Agent.sendMessage (file attachments)', () => {
263
+ beforeEach(() => {
264
+ jest.clearAllMocks();
265
+ });
266
+ const agent = () => {
267
+ const http = new HttpClient({
268
+ apiKey: 'test-key',
269
+ stream: false,
270
+ pollIntervalMs: 20,
271
+ });
272
+ return new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
273
+ };
274
+ it('should route image and non-image URIs into images vs files on the run request', async () => {
275
+ const imageFile = {
276
+ id: 'file-img',
277
+ uri: 'inf://files/img',
278
+ filename: 'photo.png',
279
+ content_type: 'image/png',
280
+ };
281
+ const docFile = {
282
+ id: 'file-doc',
283
+ uri: 'inf://files/doc',
284
+ filename: 'notes.pdf',
285
+ content_type: 'application/pdf',
286
+ };
287
+ mockJsonResponse({
288
+ success: true,
289
+ data: {
290
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
291
+ assistant_message: makeMessage(),
292
+ },
293
+ });
294
+ mockJsonResponse({ success: true, data: { status: ChatStatusBusy } });
295
+ mockJsonResponse({
296
+ success: true,
297
+ data: { id: 'chat-1', status: ChatStatusBusy, chat_messages: [] },
298
+ });
299
+ mockJsonResponse({ success: true, data: { status: ChatStatusIdle } });
300
+ mockJsonResponse({
301
+ success: true,
302
+ data: { id: 'chat-1', status: ChatStatusIdle, chat_messages: [] },
303
+ });
304
+ await agent().sendMessage('see attachments', {
305
+ stream: false,
306
+ files: [imageFile, docFile],
307
+ });
308
+ const runCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/agents/run'));
309
+ const body = JSON.parse(String(runCall[1].body));
310
+ expect(body.input.images).toEqual(['inf://files/img']);
311
+ expect(body.input.files).toEqual(['inf://files/doc']);
312
+ expect(mockFetch.mock.calls.filter(([url]) => String(url).includes('/files')).length).toBe(0);
313
+ });
314
+ });
315
+ describe('Agent lifecycle', () => {
316
+ beforeEach(() => {
317
+ jest.clearAllMocks();
318
+ });
319
+ const agent = () => {
320
+ const http = new HttpClient({
321
+ apiKey: 'test-key',
322
+ stream: false,
323
+ pollIntervalMs: 20,
324
+ });
325
+ return new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
326
+ };
327
+ it('stopChat should no-op when there is no active chat', async () => {
328
+ await agent().stopChat();
329
+ expect(mockFetch).not.toHaveBeenCalled();
330
+ });
331
+ it('stopChat should POST to /chats/{id}/stop when a chat exists', async () => {
332
+ const agentInstance = agent();
333
+ mockJsonResponse({
334
+ success: true,
335
+ data: {
336
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
337
+ assistant_message: makeMessage(),
338
+ },
339
+ });
340
+ mockJsonResponse({ success: true, data: { status: ChatStatusBusy } });
341
+ mockJsonResponse({
342
+ success: true,
343
+ data: { id: 'chat-1', status: ChatStatusBusy, chat_messages: [] },
344
+ });
345
+ mockJsonResponse({ success: true, data: { status: ChatStatusIdle } });
346
+ mockJsonResponse({
347
+ success: true,
348
+ data: { id: 'chat-1', status: ChatStatusIdle, chat_messages: [] },
349
+ });
350
+ await agentInstance.sendMessage('hello', { stream: false });
351
+ jest.clearAllMocks();
352
+ mockJsonResponse({ success: true, data: null });
353
+ await agentInstance.stopChat();
354
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/chats/chat-1/stop'), expect.anything());
355
+ });
356
+ it('reset should clear chat state so stopChat is a no-op', async () => {
357
+ const agentInstance = agent();
358
+ mockJsonResponse({
359
+ success: true,
360
+ data: {
361
+ user_message: makeMessage({ id: 'user-1', role: 'user' }),
362
+ assistant_message: makeMessage(),
363
+ },
364
+ });
365
+ mockJsonResponse({ success: true, data: { status: ChatStatusBusy } });
366
+ mockJsonResponse({
367
+ success: true,
368
+ data: { id: 'chat-1', status: ChatStatusBusy, chat_messages: [] },
369
+ });
370
+ mockJsonResponse({ success: true, data: { status: ChatStatusIdle } });
371
+ mockJsonResponse({
372
+ success: true,
373
+ data: { id: 'chat-1', status: ChatStatusIdle, chat_messages: [] },
374
+ });
375
+ await agentInstance.sendMessage('hello', { stream: false });
376
+ agentInstance.reset();
377
+ jest.clearAllMocks();
378
+ await agentInstance.stopChat();
379
+ expect(mockFetch).not.toHaveBeenCalled();
380
+ });
381
+ });
124
382
  describe('Agent.submitToolResult', () => {
125
383
  beforeEach(() => {
126
384
  jest.clearAllMocks();
@@ -8,7 +8,8 @@ import { AppSessionDTO } from '../types';
8
8
  *
9
9
  * @example
10
10
  * ```typescript
11
- * const client = new Inference({ apiKey: '...' });
11
+ * import { inference } from '@inferencesh/sdk';
12
+ * const client = inference({ apiKey: '...' });
12
13
  *
13
14
  * // Get session info
14
15
  * const info = await client.sessions.get('sess_abc123');
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * @example
8
8
  * ```typescript
9
- * const client = new Inference({ apiKey: '...' });
9
+ * import { inference } from '@inferencesh/sdk';
10
+ * const client = inference({ apiKey: '...' });
10
11
  *
11
12
  * // Get session info
12
13
  * const info = await client.sessions.get('sess_abc123');
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { HttpClient } from '../http/client';
2
+ import { SessionsAPI } from './sessions';
3
+ const mockFetch = jest.fn();
4
+ global.fetch = mockFetch;
5
+ function mockJsonResponse(body) {
6
+ mockFetch.mockResolvedValueOnce({
7
+ ok: true,
8
+ status: 200,
9
+ text: () => Promise.resolve(JSON.stringify(body)),
10
+ });
11
+ }
12
+ describe('SessionsAPI', () => {
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+ });
16
+ const api = () => new SessionsAPI(new HttpClient({ apiKey: 'test-key' }));
17
+ it('should GET /sessions/{id} for get()', async () => {
18
+ const session = { id: 'sess_1', status: 'active' };
19
+ mockJsonResponse({ success: true, data: session });
20
+ const result = await api().get('sess_1');
21
+ expect(result).toEqual(session);
22
+ const [url, init] = mockFetch.mock.calls[0];
23
+ expect(url).toContain('/sessions/sess_1');
24
+ expect(init.method).toBe('GET');
25
+ });
26
+ it('should return an empty array when list() response is null', async () => {
27
+ mockJsonResponse({ success: true, data: null });
28
+ const result = await api().list();
29
+ expect(result).toEqual([]);
30
+ const [url] = mockFetch.mock.calls[0];
31
+ expect(url).toContain('/sessions');
32
+ });
33
+ it('should POST /sessions/{id}/keepalive for keepalive()', async () => {
34
+ const session = { id: 'sess_2', status: 'active' };
35
+ mockJsonResponse({ success: true, data: session });
36
+ const result = await api().keepalive('sess_2');
37
+ expect(result).toEqual(session);
38
+ const [url, init] = mockFetch.mock.calls[0];
39
+ expect(url).toContain('/sessions/sess_2/keepalive');
40
+ expect(init.method).toBe('POST');
41
+ });
42
+ it('should DELETE /sessions/{id} for end()', async () => {
43
+ mockJsonResponse({ success: true, data: null });
44
+ await api().end('sess_3');
45
+ const [url, init] = mockFetch.mock.calls[0];
46
+ expect(url).toContain('/sessions/sess_3');
47
+ expect(init.method).toBe('DELETE');
48
+ });
49
+ });
@@ -62,6 +62,20 @@ describe('TasksAPI.run (polling mode)', () => {
62
62
  mockJsonResponse({ success: true, data: cancelledTask });
63
63
  await expect(api().run({ app: 'test-app', input: {} }, {}, { wait: true, stream: false })).rejects.toThrow('task cancelled');
64
64
  });
65
+ it('should reject when full task fetch fails after status change', async () => {
66
+ const runningTask = makeTask();
67
+ mockJsonResponse({ success: true, data: runningTask });
68
+ mockJsonResponse({ success: true, data: { status: TaskStatusRunning } });
69
+ mockJsonResponse({ success: true, data: { status: TaskStatusCompleted } });
70
+ mockFetch.mockRejectedValueOnce(new Error('network down'));
71
+ await expect(api().run({ app: 'test-app', input: {} }, {}, { wait: true, stream: false })).rejects.toThrow('network down');
72
+ });
73
+ it('should reject when status polling fails', async () => {
74
+ const runningTask = makeTask();
75
+ mockJsonResponse({ success: true, data: runningTask });
76
+ mockFetch.mockRejectedValueOnce(new Error('status endpoint down'));
77
+ await expect(api().run({ app: 'test-app', input: {} }, {}, { wait: true, stream: false })).rejects.toThrow('status endpoint down');
78
+ });
65
79
  it('should parse string terminal statuses from the status endpoint', async () => {
66
80
  const runningTask = makeTask();
67
81
  const completedTask = makeTask({ status: TaskStatusCompleted });
@@ -211,5 +211,144 @@ describe('StreamManager', () => {
211
211
  await new Promise((r) => setTimeout(r, 50));
212
212
  expect(createEventSource).toHaveBeenCalledTimes(1);
213
213
  });
214
+ it('should stop reconnecting after maxReconnects initial failures', async () => {
215
+ jest.useFakeTimers();
216
+ const onError = jest.fn();
217
+ const createEventSource = jest
218
+ .fn()
219
+ .mockResolvedValue(mockEventSource);
220
+ const manager = new StreamManager({
221
+ createEventSource,
222
+ autoReconnect: true,
223
+ maxReconnects: 2,
224
+ reconnectDelayMs: 100,
225
+ onError,
226
+ });
227
+ await manager.connect();
228
+ mockEventSource.onerror?.({});
229
+ jest.advanceTimersByTime(100);
230
+ await Promise.resolve();
231
+ expect(createEventSource).toHaveBeenCalledTimes(2);
232
+ mockEventSource.onerror?.({});
233
+ jest.advanceTimersByTime(100);
234
+ await Promise.resolve();
235
+ expect(createEventSource).toHaveBeenCalledTimes(3);
236
+ mockEventSource.onerror?.({});
237
+ jest.advanceTimersByTime(100);
238
+ await Promise.resolve();
239
+ expect(createEventSource).toHaveBeenCalledTimes(3);
240
+ jest.useRealTimers();
241
+ });
242
+ it('should call onError and stop when createEventSource throws', async () => {
243
+ jest.useFakeTimers();
244
+ const onError = jest.fn();
245
+ const createEventSource = jest
246
+ .fn()
247
+ .mockRejectedValue(new Error('connection refused'));
248
+ const manager = new StreamManager({
249
+ createEventSource,
250
+ autoReconnect: false,
251
+ onError,
252
+ });
253
+ await manager.connect();
254
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'connection refused' }));
255
+ jest.useRealTimers();
256
+ });
257
+ });
258
+ describe('stopAfter and clearStopTimeout', () => {
259
+ it('should stop after the configured delay', async () => {
260
+ jest.useFakeTimers();
261
+ const onStop = jest.fn();
262
+ const manager = new StreamManager({
263
+ createEventSource: async () => mockEventSource,
264
+ onStop,
265
+ });
266
+ await manager.connect();
267
+ manager.stopAfter(5000);
268
+ jest.advanceTimersByTime(5000);
269
+ expect(mockEventSource.close).toHaveBeenCalled();
270
+ expect(onStop).toHaveBeenCalled();
271
+ jest.useRealTimers();
272
+ });
273
+ it('should cancel a pending stop when clearStopTimeout is called', async () => {
274
+ jest.useFakeTimers();
275
+ const manager = new StreamManager({
276
+ createEventSource: async () => mockEventSource,
277
+ });
278
+ await manager.connect();
279
+ manager.stopAfter(5000);
280
+ manager.clearStopTimeout();
281
+ jest.advanceTimersByTime(5000);
282
+ expect(mockEventSource.close).not.toHaveBeenCalled();
283
+ jest.useRealTimers();
284
+ });
285
+ });
286
+ describe('addEventListener lifecycle', () => {
287
+ it('should register listeners on an existing connection when added after connect', async () => {
288
+ const typedListeners = {};
289
+ const eventSource = {
290
+ onmessage: null,
291
+ onerror: null,
292
+ close: jest.fn(),
293
+ addEventListener: (eventName, handler) => {
294
+ const listeners = typedListeners[eventName] || new Set();
295
+ listeners.add(handler);
296
+ typedListeners[eventName] = listeners;
297
+ },
298
+ };
299
+ const manager = new StreamManager({
300
+ createEventSource: async () => eventSource,
301
+ });
302
+ await manager.connect();
303
+ const onChat = jest.fn();
304
+ manager.addEventListener('chats', onChat);
305
+ const payload = { id: 'chat-2' };
306
+ typedListeners.chats?.forEach((handler) => handler({ data: JSON.stringify(payload) }));
307
+ expect(onChat).toHaveBeenCalledWith(payload);
308
+ });
309
+ it('should remove a listener when the cleanup function is called', async () => {
310
+ const typedListeners = {};
311
+ const eventSource = {
312
+ onmessage: null,
313
+ onerror: null,
314
+ close: jest.fn(),
315
+ addEventListener: (eventName, handler) => {
316
+ const listeners = typedListeners[eventName] || new Set();
317
+ listeners.add(handler);
318
+ typedListeners[eventName] = listeners;
319
+ },
320
+ };
321
+ const manager = new StreamManager({
322
+ createEventSource: async () => eventSource,
323
+ });
324
+ const onChat = jest.fn();
325
+ const unsubscribe = manager.addEventListener('chats', onChat);
326
+ await manager.connect();
327
+ unsubscribe();
328
+ typedListeners.chats?.forEach((handler) => handler({ data: '{"id":"chat-3"}' }));
329
+ expect(onChat).not.toHaveBeenCalled();
330
+ });
331
+ it('should call onError when typed event payload is invalid JSON', async () => {
332
+ const typedListeners = {};
333
+ const eventSource = {
334
+ onmessage: null,
335
+ onerror: null,
336
+ close: jest.fn(),
337
+ addEventListener: (eventName, handler) => {
338
+ const listeners = typedListeners[eventName] || new Set();
339
+ listeners.add(handler);
340
+ typedListeners[eventName] = listeners;
341
+ },
342
+ };
343
+ const onError = jest.fn();
344
+ const manager = new StreamManager({
345
+ createEventSource: async () => eventSource,
346
+ onError,
347
+ });
348
+ manager.addEventListener('chats', jest.fn());
349
+ await manager.connect();
350
+ typedListeners.chats?.forEach((handler) => handler({ data: 'not-json' }));
351
+ expect(onError).toHaveBeenCalled();
352
+ });
214
353
  });
215
354
  });
@@ -1,5 +1,5 @@
1
- import { tool, appTool, agentTool, webhookTool, internalTools, string, number, integer, boolean, enumOf, object, array, optional, } from './tool-builder';
2
- import { ToolTypeClient, ToolTypeApp, ToolTypeAgent, ToolTypeHook } from './types';
1
+ import { tool, appTool, agentTool, webhookTool, httpTool, callTool, mcpTool, internalTools, string, number, integer, boolean, enumOf, object, array, optional, } from './tool-builder';
2
+ import { ToolTypeClient, ToolTypeApp, ToolTypeAgent, ToolTypeHook, ToolTypeHTTP, ToolTypeMCP, IntegrationProviderGoogle, } from './types';
3
3
  describe('Schema Helpers', () => {
4
4
  describe('string', () => {
5
5
  it('creates string schema without description', () => {
@@ -109,6 +109,18 @@ describe('ClientToolBuilder (tool)', () => {
109
109
  const t = tool('get_data').display('Get Data').build();
110
110
  expect(t.display_name).toBe('Get Data');
111
111
  });
112
+ it('creates tool with displayName alias', () => {
113
+ const t = tool('get_data').displayName('Get Data').build();
114
+ expect(t.display_name).toBe('Get Data');
115
+ });
116
+ it('returns schema and handler from handler()', async () => {
117
+ const clientTool = tool('greet')
118
+ .describe('Say hi')
119
+ .param('name', string('Name'))
120
+ .handler(async (args) => `Hello, ${args.name}`);
121
+ expect(clientTool.schema.name).toBe('greet');
122
+ expect(await clientTool.handler({ name: 'Ada' })).toBe('Hello, Ada');
123
+ });
112
124
  it('creates tool with parameters', () => {
113
125
  const t = tool('add')
114
126
  .param('a', number('First number'))
@@ -240,6 +252,61 @@ describe('AgentToolBuilder (agentTool)', () => {
240
252
  expect(t.require_approval).toBe(true);
241
253
  });
242
254
  });
255
+ describe('HTTPToolBuilder (httpTool)', () => {
256
+ it('includes custom method and headers in the built tool', () => {
257
+ const t = httpTool('fetch', 'https://api.example.com/data')
258
+ .method('GET')
259
+ .header('X-Custom', '1')
260
+ .build();
261
+ expect(t.http?.method).toBe('GET');
262
+ expect(t.http?.headers).toEqual({ 'X-Custom': '1' });
263
+ });
264
+ it('omits method when POST is the default', () => {
265
+ const t = httpTool('post', 'https://api.example.com').build();
266
+ expect(t.http?.method).toBeUndefined();
267
+ });
268
+ it('should attach integration auth with provider and integration id', () => {
269
+ const t = httpTool('gmail_send', 'https://api.example.com/send')
270
+ .auth({ integration: IntegrationProviderGoogle, integrationId: 'int-123' })
271
+ .build();
272
+ expect(t.type).toBe(ToolTypeHTTP);
273
+ expect(t.http?.auth).toEqual({
274
+ type: 'integration',
275
+ provider: IntegrationProviderGoogle,
276
+ integration_id: 'int-123',
277
+ });
278
+ });
279
+ it('should attach api key auth with default header', () => {
280
+ const t = httpTool('fetch', 'https://api.example.com').auth({ apiKey: 'KEY' }).build();
281
+ expect(t.http?.auth).toEqual({
282
+ type: 'api_key',
283
+ secret: 'KEY',
284
+ header: 'X-API-Key',
285
+ });
286
+ });
287
+ it('should attach bearer auth', () => {
288
+ const t = httpTool('fetch', 'https://api.example.com')
289
+ .auth({ bearer: 'token-abc' })
290
+ .build();
291
+ expect(t.http?.auth).toEqual({
292
+ type: 'bearer',
293
+ secret: 'token-abc',
294
+ });
295
+ });
296
+ it('callTool should be an alias for httpTool', () => {
297
+ const t = callTool('ping', 'https://api.example.com/ping').method('GET').build();
298
+ expect(t.type).toBe(ToolTypeHTTP);
299
+ expect(t.http?.method).toBe('GET');
300
+ });
301
+ });
302
+ describe('MCPToolBuilder (mcpTool)', () => {
303
+ it('creates MCP tool with integration and tool name', () => {
304
+ const t = mcpTool('search_docs', 'int-mcp-1', 'search').describe('Search docs').build();
305
+ expect(t.type).toBe(ToolTypeMCP);
306
+ expect(t.mcp).toEqual({ integration_id: 'int-mcp-1', tool_name: 'search' });
307
+ expect(t.description).toBe('Search docs');
308
+ });
309
+ });
243
310
  describe('WebhookToolBuilder (webhookTool)', () => {
244
311
  it('creates webhook tool with URL', () => {
245
312
  const t = webhookTool('notify', 'https://api.example.com/webhook')