@strands-agents/sdk 0.2.2 → 0.3.0

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.
Files changed (36) hide show
  1. package/dist/src/models/__tests__/gemini.test.js +432 -135
  2. package/dist/src/models/__tests__/gemini.test.js.map +1 -1
  3. package/dist/src/models/gemini/adapters.d.ts +2 -1
  4. package/dist/src/models/gemini/adapters.d.ts.map +1 -1
  5. package/dist/src/models/gemini/adapters.js +97 -10
  6. package/dist/src/models/gemini/adapters.js.map +1 -1
  7. package/dist/src/models/gemini/model.d.ts.map +1 -1
  8. package/dist/src/models/gemini/model.js +37 -1
  9. package/dist/src/models/gemini/model.js.map +1 -1
  10. package/dist/src/models/gemini/types.d.ts +9 -1
  11. package/dist/src/models/gemini/types.d.ts.map +1 -1
  12. package/dist/src/models/model.d.ts.map +1 -1
  13. package/dist/src/models/model.js +4 -0
  14. package/dist/src/models/model.js.map +1 -1
  15. package/dist/src/models/streaming.d.ts +5 -0
  16. package/dist/src/models/streaming.d.ts.map +1 -1
  17. package/dist/src/tsconfig.tsbuildinfo +1 -1
  18. package/dist/src/types/messages.d.ts +10 -0
  19. package/dist/src/types/messages.d.ts.map +1 -1
  20. package/dist/src/types/messages.js +8 -0
  21. package/dist/src/types/messages.js.map +1 -1
  22. package/dist/src/vended-tools/bash/__tests__/bash.test.node.d.ts +2 -0
  23. package/dist/src/vended-tools/bash/__tests__/bash.test.node.d.ts.map +1 -0
  24. package/dist/src/vended-tools/bash/__tests__/{bash.test.js → bash.test.node.js} +3 -4
  25. package/dist/src/vended-tools/bash/__tests__/bash.test.node.js.map +1 -0
  26. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.node.d.ts +2 -0
  27. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.node.d.ts.map +1 -0
  28. package/dist/src/vended-tools/file_editor/__tests__/{file-editor.test.js → file-editor.test.node.js} +1 -1
  29. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.node.js.map +1 -0
  30. package/package.json +1 -1
  31. package/dist/src/vended-tools/bash/__tests__/bash.test.d.ts +0 -2
  32. package/dist/src/vended-tools/bash/__tests__/bash.test.d.ts.map +0 -1
  33. package/dist/src/vended-tools/bash/__tests__/bash.test.js.map +0 -1
  34. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.d.ts +0 -2
  35. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.d.ts.map +0 -1
  36. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.js.map +0 -1
@@ -1,10 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { GoogleGenAI } from '@google/genai';
2
+ import { GoogleGenAI, FunctionCallingConfigMode } from '@google/genai';
3
3
  import { collectIterator } from '../../__fixtures__/model-test-helpers.js';
4
4
  import { GeminiModel } from '../gemini/model.js';
5
5
  import { ContextWindowOverflowError } from '../../errors.js';
6
- import { CachePointBlock, GuardContentBlock, ReasoningBlock, ToolUseBlock } from '../../types/messages.js';
7
- import { formatMessages } from '../gemini/adapters.js';
6
+ import { CachePointBlock, GuardContentBlock, ReasoningBlock, TextBlock, ToolResultBlock, ToolUseBlock, } from '../../types/messages.js';
7
+ import { formatMessages, mapChunkToEvents } from '../gemini/adapters.js';
8
8
  import { ImageBlock, DocumentBlock, VideoBlock } from '../../types/media.js';
9
9
  /**
10
10
  * Helper to create a mock Gemini client with streaming support
@@ -16,6 +16,49 @@ function createMockClient(streamGenerator) {
16
16
  },
17
17
  };
18
18
  }
19
+ /**
20
+ * Helper to create a mock Gemini client that captures the request parameters.
21
+ * Returns the client and a captured object with `config` and `contents` fields
22
+ * populated after a stream call.
23
+ */
24
+ function createMockClientWithCapture() {
25
+ const captured = {};
26
+ const client = {
27
+ models: {
28
+ generateContentStream: vi.fn(async (params) => {
29
+ Object.assign(captured, params);
30
+ return (async function* () {
31
+ yield { candidates: [{ finishReason: 'STOP' }] };
32
+ })();
33
+ }),
34
+ },
35
+ };
36
+ return { client, captured };
37
+ }
38
+ /**
39
+ * Helper to set up a capture-based test with provider, captured params, and a default user message.
40
+ */
41
+ function setupCaptureTest() {
42
+ const { client, captured } = createMockClientWithCapture();
43
+ const provider = new GeminiModel({ client });
44
+ const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
45
+ return { provider, captured, messages };
46
+ }
47
+ /**
48
+ * Helper to set up a stream-based test with a mock client, provider, and default user message.
49
+ */
50
+ function setupStreamTest(streamGenerator) {
51
+ const client = createMockClient(streamGenerator);
52
+ const provider = new GeminiModel({ client });
53
+ const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
54
+ return { provider, messages };
55
+ }
56
+ /**
57
+ * Helper to format a single content block via formatMessages.
58
+ */
59
+ function formatBlock(block, role = 'user') {
60
+ return formatMessages([{ type: 'message', role, content: [block] }]);
61
+ }
19
62
  describe('GeminiModel', () => {
20
63
  beforeEach(() => {
21
64
  vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
@@ -66,7 +109,7 @@ describe('GeminiModel', () => {
66
109
  await expect(collectIterator(provider.stream([]))).rejects.toThrow('At least one message is required');
67
110
  });
68
111
  it('emits message start and stop events', async () => {
69
- const mockClient = createMockClient(async function* () {
112
+ const { provider, messages } = setupStreamTest(async function* () {
70
113
  yield {
71
114
  candidates: [
72
115
  {
@@ -76,14 +119,12 @@ describe('GeminiModel', () => {
76
119
  };
77
120
  yield { candidates: [{ finishReason: 'STOP' }] };
78
121
  });
79
- const provider = new GeminiModel({ client: mockClient });
80
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
81
122
  const events = await collectIterator(provider.stream(messages));
82
123
  expect(events[0]).toEqual({ type: 'modelMessageStartEvent', role: 'assistant' });
83
124
  expect(events[events.length - 1]).toEqual({ type: 'modelMessageStopEvent', stopReason: 'endTurn' });
84
125
  });
85
126
  it('emits text content block events', async () => {
86
- const mockClient = createMockClient(async function* () {
127
+ const { provider, messages } = setupStreamTest(async function* () {
87
128
  yield {
88
129
  candidates: [
89
130
  {
@@ -100,8 +141,6 @@ describe('GeminiModel', () => {
100
141
  };
101
142
  yield { candidates: [{ finishReason: 'STOP' }] };
102
143
  });
103
- const provider = new GeminiModel({ client: mockClient });
104
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
105
144
  const events = await collectIterator(provider.stream(messages));
106
145
  expect(events).toHaveLength(6);
107
146
  expect(events[0]).toEqual({ type: 'modelMessageStartEvent', role: 'assistant' });
@@ -118,7 +157,7 @@ describe('GeminiModel', () => {
118
157
  expect(events[5]).toEqual({ type: 'modelMessageStopEvent', stopReason: 'endTurn' });
119
158
  });
120
159
  it('emits usage metadata when available', async () => {
121
- const mockClient = createMockClient(async function* () {
160
+ const { provider, messages } = setupStreamTest(async function* () {
122
161
  yield {
123
162
  candidates: [
124
163
  {
@@ -132,11 +171,8 @@ describe('GeminiModel', () => {
132
171
  };
133
172
  yield { candidates: [{ finishReason: 'STOP' }] };
134
173
  });
135
- const provider = new GeminiModel({ client: mockClient });
136
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
137
174
  const events = await collectIterator(provider.stream(messages));
138
175
  const metadataEvent = events.find((e) => e.type === 'modelMetadataEvent');
139
- expect(metadataEvent).toBeDefined();
140
176
  expect(metadataEvent).toEqual({
141
177
  type: 'modelMetadataEvent',
142
178
  usage: {
@@ -147,7 +183,7 @@ describe('GeminiModel', () => {
147
183
  });
148
184
  });
149
185
  it('handles MAX_TOKENS finish reason', async () => {
150
- const mockClient = createMockClient(async function* () {
186
+ const { provider, messages } = setupStreamTest(async function* () {
151
187
  yield {
152
188
  candidates: [
153
189
  {
@@ -157,8 +193,6 @@ describe('GeminiModel', () => {
157
193
  };
158
194
  yield { candidates: [{ finishReason: 'MAX_TOKENS' }] };
159
195
  });
160
- const provider = new GeminiModel({ client: mockClient });
161
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
162
196
  const events = await collectIterator(provider.stream(messages));
163
197
  const stopEvent = events.find((e) => e.type === 'modelMessageStopEvent');
164
198
  expect(stopEvent).toBeDefined();
@@ -197,81 +231,37 @@ describe('GeminiModel', () => {
197
231
  });
198
232
  });
199
233
  describe('system prompt', () => {
200
- /**
201
- * Helper to create a mock client that captures the request config
202
- */
203
- function createMockClientWithCapture(captureContainer) {
204
- return {
205
- models: {
206
- generateContentStream: vi.fn(async ({ config }) => {
207
- captureContainer.config = config;
208
- return (async function* () {
209
- yield { candidates: [{ finishReason: 'STOP' }] };
210
- })();
211
- }),
212
- },
213
- };
214
- }
215
234
  it('passes string system prompt to config', async () => {
216
- const captured = { config: null };
217
- const mockClient = createMockClientWithCapture(captured);
218
- const provider = new GeminiModel({ client: mockClient });
219
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
235
+ const { provider, captured, messages } = setupCaptureTest();
220
236
  await collectIterator(provider.stream(messages, { systemPrompt: 'You are a helpful assistant' }));
221
- expect(captured.config).toBeDefined();
222
237
  const config = captured.config;
223
238
  expect(config.systemInstruction).toBe('You are a helpful assistant');
224
239
  });
225
240
  it('ignores empty string system prompt', async () => {
226
- const captured = { config: null };
227
- const mockClient = createMockClientWithCapture(captured);
228
- const provider = new GeminiModel({ client: mockClient });
229
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
241
+ const { provider, captured, messages } = setupCaptureTest();
230
242
  await collectIterator(provider.stream(messages, { systemPrompt: ' ' }));
231
- expect(captured.config).toBeDefined();
232
243
  const config = captured.config;
233
244
  expect(config.systemInstruction).toBeUndefined();
234
245
  });
235
246
  });
236
247
  describe('message formatting', () => {
237
- /**
238
- * Helper to create a mock client that captures the request contents
239
- */
240
- function createMockClientWithCapture(captureContainer) {
241
- return {
242
- models: {
243
- generateContentStream: vi.fn(async ({ contents }) => {
244
- captureContainer.contents = contents;
245
- return (async function* () {
246
- yield { candidates: [{ finishReason: 'STOP' }] };
247
- })();
248
- }),
249
- },
250
- };
251
- }
252
248
  it('formats user messages correctly', async () => {
253
- const captured = { contents: null };
254
- const mockClient = createMockClientWithCapture(captured);
255
- const provider = new GeminiModel({ client: mockClient });
249
+ const { provider, captured } = setupCaptureTest();
256
250
  const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hello' }] }];
257
251
  await collectIterator(provider.stream(messages));
258
- expect(captured.contents).toBeDefined();
259
252
  const contents = captured.contents;
260
253
  expect(contents).toHaveLength(1);
261
254
  expect(contents[0]?.role).toBe('user');
262
255
  expect(contents[0]?.parts[0]?.text).toBe('Hello');
263
256
  });
264
257
  it('formats assistant messages correctly', async () => {
265
- const captured = { contents: null };
266
- const mockClient = createMockClientWithCapture(captured);
267
- const provider = new GeminiModel({ client: mockClient });
258
+ const { provider, captured } = setupCaptureTest();
268
259
  const messages = [
269
260
  { type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] },
270
261
  { type: 'message', role: 'assistant', content: [{ type: 'textBlock', text: 'Hello!' }] },
271
262
  { type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'How are you?' }] },
272
263
  ];
273
264
  await collectIterator(provider.stream(messages));
274
- expect(captured.contents).toBeDefined();
275
265
  const contents = captured.contents;
276
266
  expect(contents).toHaveLength(3);
277
267
  expect(contents[0]?.role).toBe('user');
@@ -286,25 +276,20 @@ describe('GeminiModel', () => {
286
276
  format: 'png',
287
277
  source: { bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]) },
288
278
  });
289
- const messages = [{ type: 'message', role: 'user', content: [imageBlock] }];
290
- const contents = formatMessages(messages);
279
+ const contents = formatBlock(imageBlock);
291
280
  expect(contents).toHaveLength(1);
292
- const part = contents[0].parts[0];
293
- expect(part).toHaveProperty('inlineData');
294
- expect(part.inlineData.mimeType).toBe('image/png');
281
+ expect(contents[0].parts).toEqual([{ inlineData: { data: 'iVBORw==', mimeType: 'image/png' } }]);
295
282
  });
296
283
  it('formats image with URL source as fileData', () => {
297
284
  const imageBlock = new ImageBlock({
298
285
  format: 'jpeg',
299
286
  source: { url: 'https://example.com/image.jpg' },
300
287
  });
301
- const messages = [{ type: 'message', role: 'user', content: [imageBlock] }];
302
- const contents = formatMessages(messages);
288
+ const contents = formatBlock(imageBlock);
303
289
  expect(contents).toHaveLength(1);
304
- const part = contents[0].parts[0];
305
- expect(part).toHaveProperty('fileData');
306
- expect(part.fileData.fileUri).toBe('https://example.com/image.jpg');
307
- expect(part.fileData.mimeType).toBe('image/jpeg');
290
+ expect(contents[0].parts).toEqual([
291
+ { fileData: { fileUri: 'https://example.com/image.jpg', mimeType: 'image/jpeg' } },
292
+ ]);
308
293
  });
309
294
  it('skips image with S3 source and logs warning', () => {
310
295
  const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
@@ -312,8 +297,7 @@ describe('GeminiModel', () => {
312
297
  format: 'png',
313
298
  source: { s3Location: { uri: 's3://test/image.png' } },
314
299
  });
315
- const messages = [{ type: 'message', role: 'user', content: [imageBlock] }];
316
- const contents = formatMessages(messages);
300
+ const contents = formatBlock(imageBlock);
317
301
  // Message with no valid parts is not included
318
302
  expect(contents).toHaveLength(0);
319
303
  expect(warnSpy).toHaveBeenCalled();
@@ -327,12 +311,9 @@ describe('GeminiModel', () => {
327
311
  format: 'pdf',
328
312
  source: { bytes: new Uint8Array([0x25, 0x50, 0x44, 0x46]) },
329
313
  });
330
- const messages = [{ type: 'message', role: 'user', content: [docBlock] }];
331
- const contents = formatMessages(messages);
314
+ const contents = formatBlock(docBlock);
332
315
  expect(contents).toHaveLength(1);
333
- const part = contents[0].parts[0];
334
- expect(part).toHaveProperty('inlineData');
335
- expect(part.inlineData.mimeType).toBe('application/pdf');
316
+ expect(contents[0].parts).toEqual([{ inlineData: { data: 'JVBERg==', mimeType: 'application/pdf' } }]);
336
317
  });
337
318
  it('formats document with text source as inlineData bytes', () => {
338
319
  const docBlock = new DocumentBlock({
@@ -340,12 +321,11 @@ describe('GeminiModel', () => {
340
321
  format: 'txt',
341
322
  source: { text: 'Document content here' },
342
323
  });
343
- const messages = [{ type: 'message', role: 'user', content: [docBlock] }];
344
- const contents = formatMessages(messages);
324
+ const contents = formatBlock(docBlock);
345
325
  expect(contents).toHaveLength(1);
346
- const part = contents[0].parts[0];
347
- expect(part).toHaveProperty('inlineData');
348
- expect(part.inlineData.mimeType).toBe('text/plain');
326
+ expect(contents[0].parts).toEqual([
327
+ { inlineData: { data: 'RG9jdW1lbnQgY29udGVudCBoZXJl', mimeType: 'text/plain' } },
328
+ ]);
349
329
  });
350
330
  it('formats document with content block source as separate text parts', () => {
351
331
  const docBlock = new DocumentBlock({
@@ -353,8 +333,7 @@ describe('GeminiModel', () => {
353
333
  format: 'txt',
354
334
  source: { content: [{ text: 'Line 1' }, { text: 'Line 2' }] },
355
335
  });
356
- const messages = [{ type: 'message', role: 'user', content: [docBlock] }];
357
- const contents = formatMessages(messages);
336
+ const contents = formatBlock(docBlock);
358
337
  expect(contents).toHaveLength(1);
359
338
  expect(contents[0].parts).toEqual([{ text: 'Line 1' }, { text: 'Line 2' }]);
360
339
  });
@@ -365,68 +344,56 @@ describe('GeminiModel', () => {
365
344
  format: 'mp4',
366
345
  source: { bytes: new Uint8Array([0x00, 0x00, 0x00, 0x1c]) },
367
346
  });
368
- const messages = [{ type: 'message', role: 'user', content: [videoBlock] }];
369
- const contents = formatMessages(messages);
347
+ const contents = formatBlock(videoBlock);
370
348
  expect(contents).toHaveLength(1);
371
- const part = contents[0].parts[0];
372
- expect(part).toHaveProperty('inlineData');
373
- expect(part.inlineData.mimeType).toBe('video/mp4');
349
+ expect(contents[0].parts).toEqual([{ inlineData: { data: 'AAAAHA==', mimeType: 'video/mp4' } }]);
374
350
  });
375
351
  });
376
352
  describe('reasoning content', () => {
377
353
  it('formats reasoning block with thought flag', () => {
378
354
  const reasoningBlock = new ReasoningBlock({ text: 'Let me think about this...' });
379
- const messages = [{ type: 'message', role: 'assistant', content: [reasoningBlock] }];
380
- const contents = formatMessages(messages);
355
+ const contents = formatBlock(reasoningBlock, 'assistant');
381
356
  expect(contents).toHaveLength(1);
382
- const part = contents[0].parts[0];
383
- expect(part).toHaveProperty('text', 'Let me think about this...');
384
- expect(part).toHaveProperty('thought', true);
357
+ expect(contents[0].parts).toEqual([{ text: 'Let me think about this...', thought: true }]);
385
358
  });
386
359
  it('includes thought signature when present', () => {
387
360
  const reasoningBlock = new ReasoningBlock({ text: 'Thinking...', signature: 'sig123' });
388
- const messages = [{ type: 'message', role: 'assistant', content: [reasoningBlock] }];
389
- const contents = formatMessages(messages);
390
- const part = contents[0].parts[0];
391
- expect(part.thoughtSignature).toBe('sig123');
361
+ const contents = formatBlock(reasoningBlock, 'assistant');
362
+ expect(contents).toHaveLength(1);
363
+ expect(contents[0].parts).toEqual([{ text: 'Thinking...', thought: true, thoughtSignature: 'sig123' }]);
392
364
  });
393
365
  it('skips reasoning block with empty text', () => {
394
366
  const reasoningBlock = new ReasoningBlock({ text: '' });
395
- const messages = [{ type: 'message', role: 'assistant', content: [reasoningBlock] }];
396
- const contents = formatMessages(messages);
367
+ const contents = formatBlock(reasoningBlock, 'assistant');
397
368
  expect(contents).toHaveLength(0);
398
369
  });
399
370
  });
400
371
  describe('unsupported content types', () => {
401
- it('skips cache point blocks with warning', () => {
402
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
403
- const cacheBlock = new CachePointBlock({ cacheType: 'default' });
404
- const messages = [{ type: 'message', role: 'user', content: [cacheBlock] }];
405
- const contents = formatMessages(messages);
406
- expect(contents).toHaveLength(0);
407
- warnSpy.mockRestore();
408
- });
409
- it('skips guard content blocks with warning', () => {
372
+ it.each([
373
+ { name: 'cache point', block: new CachePointBlock({ cacheType: 'default' }) },
374
+ {
375
+ name: 'guard content',
376
+ block: new GuardContentBlock({ text: { qualifiers: ['guard_content'], text: 'test' } }),
377
+ },
378
+ ])('skips $name blocks with warning', ({ block }) => {
410
379
  const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
411
- const guardBlock = new GuardContentBlock({ text: { qualifiers: ['guard_content'], text: 'test' } });
412
- const messages = [{ type: 'message', role: 'user', content: [guardBlock] }];
413
- const contents = formatMessages(messages);
380
+ const contents = formatBlock(block);
414
381
  expect(contents).toHaveLength(0);
415
382
  warnSpy.mockRestore();
416
383
  });
417
- it('skips tool use blocks with warning', () => {
418
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
419
- const toolUseBlock = new ToolUseBlock({ toolUseId: 'test-id', name: 'testTool', input: {} });
420
- const messages = [{ type: 'message', role: 'assistant', content: [toolUseBlock] }];
421
- const contents = formatMessages(messages);
422
- expect(contents).toHaveLength(0);
423
- warnSpy.mockRestore();
384
+ it('formats tool use blocks as function calls', () => {
385
+ const toolUseBlock = new ToolUseBlock({ toolUseId: 'test-id', name: 'testTool', input: { key: 'value' } });
386
+ const contents = formatBlock(toolUseBlock, 'assistant');
387
+ expect(contents).toHaveLength(1);
388
+ expect(contents[0].parts).toEqual([
389
+ { functionCall: { id: 'test-id', name: 'testTool', args: { key: 'value' } } },
390
+ ]);
424
391
  });
425
392
  });
426
393
  });
427
394
  describe('reasoning content streaming', () => {
428
395
  it('emits reasoning content delta events for thought parts', async () => {
429
- const mockClient = createMockClient(async function* () {
396
+ const { provider, messages } = setupStreamTest(async function* () {
430
397
  yield {
431
398
  candidates: [
432
399
  {
@@ -436,8 +403,6 @@ describe('GeminiModel', () => {
436
403
  };
437
404
  yield { candidates: [{ finishReason: 'STOP' }] };
438
405
  });
439
- const provider = new GeminiModel({ client: mockClient });
440
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
441
406
  const events = await collectIterator(provider.stream(messages));
442
407
  expect(events).toHaveLength(5);
443
408
  expect(events[0]).toEqual({ type: 'modelMessageStartEvent', role: 'assistant' });
@@ -450,7 +415,7 @@ describe('GeminiModel', () => {
450
415
  expect(events[4]).toEqual({ type: 'modelMessageStopEvent', stopReason: 'endTurn' });
451
416
  });
452
417
  it('handles transition from reasoning to text content', async () => {
453
- const mockClient = createMockClient(async function* () {
418
+ const { provider, messages } = setupStreamTest(async function* () {
454
419
  yield {
455
420
  candidates: [
456
421
  {
@@ -467,8 +432,6 @@ describe('GeminiModel', () => {
467
432
  };
468
433
  yield { candidates: [{ finishReason: 'STOP' }] };
469
434
  });
470
- const provider = new GeminiModel({ client: mockClient });
471
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
472
435
  const events = await collectIterator(provider.stream(messages));
473
436
  // Should have: messageStart, blockStart (reasoning), delta (reasoning), blockStop,
474
437
  // blockStart (text), delta (text), blockStop, messageStop
@@ -490,7 +453,7 @@ describe('GeminiModel', () => {
490
453
  expect(events[7]).toEqual({ type: 'modelMessageStopEvent', stopReason: 'endTurn' });
491
454
  });
492
455
  it('includes signature in reasoning delta when present', async () => {
493
- const mockClient = createMockClient(async function* () {
456
+ const { provider, messages } = setupStreamTest(async function* () {
494
457
  yield {
495
458
  candidates: [
496
459
  {
@@ -508,12 +471,346 @@ describe('GeminiModel', () => {
508
471
  };
509
472
  yield { candidates: [{ finishReason: 'STOP' }] };
510
473
  });
511
- const provider = new GeminiModel({ client: mockClient });
512
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
513
474
  const events = await collectIterator(provider.stream(messages));
514
475
  const deltaEvent = events.find((e) => e.type === 'modelContentBlockDeltaEvent' && e.delta.type === 'reasoningContentDelta');
515
- expect(deltaEvent).toBeDefined();
516
- expect(deltaEvent.delta.signature).toBe('sig456');
476
+ expect(deltaEvent).toEqual({
477
+ type: 'modelContentBlockDeltaEvent',
478
+ delta: { type: 'reasoningContentDelta', text: 'Thinking...', signature: 'sig456' },
479
+ });
480
+ });
481
+ });
482
+ describe('tool configuration', () => {
483
+ it('passes tool specs as functionDeclarations', async () => {
484
+ const { provider, captured, messages } = setupCaptureTest();
485
+ await collectIterator(provider.stream(messages, {
486
+ toolSpecs: [
487
+ {
488
+ name: 'get_weather',
489
+ description: 'Get the weather',
490
+ inputSchema: { type: 'object', properties: { city: { type: 'string' } } },
491
+ },
492
+ ],
493
+ }));
494
+ const config = captured.config;
495
+ expect(config.tools).toEqual([
496
+ {
497
+ functionDeclarations: [
498
+ {
499
+ name: 'get_weather',
500
+ description: 'Get the weather',
501
+ parametersJsonSchema: { type: 'object', properties: { city: { type: 'string' } } },
502
+ },
503
+ ],
504
+ },
505
+ ]);
506
+ });
507
+ it.each([
508
+ {
509
+ name: 'auto to AUTO',
510
+ toolChoice: { auto: {} },
511
+ expectedMode: FunctionCallingConfigMode.AUTO,
512
+ },
513
+ {
514
+ name: 'any to ANY',
515
+ toolChoice: { any: {} },
516
+ expectedMode: FunctionCallingConfigMode.ANY,
517
+ },
518
+ {
519
+ name: 'tool to ANY with allowedFunctionNames',
520
+ toolChoice: { tool: { name: 'get_weather' } },
521
+ expectedMode: FunctionCallingConfigMode.ANY,
522
+ expectedAllowedFunctionNames: ['get_weather'],
523
+ },
524
+ ])('maps toolChoice $name', async ({ toolChoice, expectedMode, expectedAllowedFunctionNames }) => {
525
+ const { provider, captured, messages } = setupCaptureTest();
526
+ await collectIterator(provider.stream(messages, {
527
+ toolSpecs: [{ name: 'get_weather', description: 'test' }],
528
+ toolChoice,
529
+ }));
530
+ const config = captured.config;
531
+ expect(config.toolConfig?.functionCallingConfig?.mode).toBe(expectedMode);
532
+ if (expectedAllowedFunctionNames) {
533
+ expect(config.toolConfig?.functionCallingConfig?.allowedFunctionNames).toEqual(expectedAllowedFunctionNames);
534
+ }
535
+ });
536
+ it('does not add tools config when no toolSpecs provided', async () => {
537
+ const { provider, captured, messages } = setupCaptureTest();
538
+ await collectIterator(provider.stream(messages));
539
+ const config = captured.config;
540
+ expect(config.tools).toBeUndefined();
541
+ });
542
+ });
543
+ describe('built-in tools', () => {
544
+ it('appends geminiTools to config.tools alongside functionDeclarations', async () => {
545
+ const { client, captured } = createMockClientWithCapture();
546
+ const provider = new GeminiModel({ client, geminiTools: [{ googleSearch: {} }] });
547
+ const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
548
+ await collectIterator(provider.stream(messages, {
549
+ toolSpecs: [
550
+ {
551
+ name: 'get_weather',
552
+ description: 'Get the weather',
553
+ inputSchema: { type: 'object', properties: { city: { type: 'string' } } },
554
+ },
555
+ ],
556
+ }));
557
+ const config = captured.config;
558
+ expect(config.tools).toHaveLength(2);
559
+ expect(config.tools[0]).toEqual({
560
+ functionDeclarations: [
561
+ {
562
+ name: 'get_weather',
563
+ description: 'Get the weather',
564
+ parametersJsonSchema: { type: 'object', properties: { city: { type: 'string' } } },
565
+ },
566
+ ],
567
+ });
568
+ expect(config.tools[1]).toEqual({ googleSearch: {} });
569
+ });
570
+ it('passes geminiTools when no toolSpecs provided', async () => {
571
+ const { client, captured } = createMockClientWithCapture();
572
+ const provider = new GeminiModel({ client, geminiTools: [{ codeExecution: {} }] });
573
+ const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
574
+ await collectIterator(provider.stream(messages));
575
+ const config = captured.config;
576
+ expect(config.tools).toHaveLength(1);
577
+ expect(config.tools[0]).toEqual({ codeExecution: {} });
578
+ });
579
+ it('does not add tools when neither geminiTools nor toolSpecs provided', async () => {
580
+ const { client, captured } = createMockClientWithCapture();
581
+ const provider = new GeminiModel({ client });
582
+ const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
583
+ await collectIterator(provider.stream(messages));
584
+ const config = captured.config;
585
+ expect(config.tools).toBeUndefined();
586
+ });
587
+ });
588
+ describe('tool use formatting', () => {
589
+ it('formats toolUseBlock with reasoningSignature as thoughtSignature', () => {
590
+ const toolUseBlock = new ToolUseBlock({
591
+ toolUseId: 'test-id',
592
+ name: 'testTool',
593
+ input: { key: 'value' },
594
+ reasoningSignature: 'sig789',
595
+ });
596
+ const contents = formatBlock(toolUseBlock, 'assistant');
597
+ expect(contents).toHaveLength(1);
598
+ expect(contents[0].parts).toEqual([
599
+ {
600
+ functionCall: { id: 'test-id', name: 'testTool', args: { key: 'value' } },
601
+ thoughtSignature: 'sig789',
602
+ },
603
+ ]);
604
+ });
605
+ it('formats toolResultBlock as functionResponse', () => {
606
+ const toolUseBlock = new ToolUseBlock({ toolUseId: 'test-id', name: 'testTool', input: {} });
607
+ const toolResultBlock = new ToolResultBlock({
608
+ toolUseId: 'test-id',
609
+ status: 'success',
610
+ content: [new TextBlock('result text')],
611
+ });
612
+ const messages = [
613
+ { type: 'message', role: 'assistant', content: [toolUseBlock] },
614
+ { type: 'message', role: 'user', content: [toolResultBlock] },
615
+ ];
616
+ const contents = formatMessages(messages);
617
+ expect(contents).toHaveLength(2);
618
+ expect(contents[1].parts[0]).toEqual({
619
+ functionResponse: {
620
+ id: 'test-id',
621
+ name: 'testTool',
622
+ response: { output: [{ text: 'result text' }] },
623
+ },
624
+ });
625
+ });
626
+ it('resolves tool name from toolUseId in toolResultBlock', () => {
627
+ const toolUseBlock = new ToolUseBlock({ toolUseId: 'abc-123', name: 'my_tool', input: {} });
628
+ const toolResultBlock = new ToolResultBlock({
629
+ toolUseId: 'abc-123',
630
+ status: 'success',
631
+ content: [new TextBlock('ok')],
632
+ });
633
+ const messages = [
634
+ { type: 'message', role: 'assistant', content: [toolUseBlock] },
635
+ { type: 'message', role: 'user', content: [toolResultBlock] },
636
+ ];
637
+ const contents = formatMessages(messages);
638
+ const resultPart = contents[1].parts[0];
639
+ const fr = resultPart.functionResponse;
640
+ expect(fr.name).toBe('my_tool');
641
+ });
642
+ it('falls back to toolUseId when tool name mapping is not found', () => {
643
+ const toolResultBlock = new ToolResultBlock({
644
+ toolUseId: 'unknown-id',
645
+ status: 'success',
646
+ content: [new TextBlock('ok')],
647
+ });
648
+ const messages = [{ type: 'message', role: 'user', content: [toolResultBlock] }];
649
+ const contents = formatMessages(messages);
650
+ const resultPart = contents[0].parts[0];
651
+ const fr = resultPart.functionResponse;
652
+ expect(fr.name).toBe('unknown-id');
653
+ });
654
+ });
655
+ describe('tool use streaming', () => {
656
+ function createStreamState() {
657
+ return {
658
+ messageStarted: true,
659
+ textContentBlockStarted: false,
660
+ reasoningContentBlockStarted: false,
661
+ hasToolCalls: false,
662
+ inputTokens: 0,
663
+ outputTokens: 0,
664
+ };
665
+ }
666
+ it('emits tool use events for function call in response', () => {
667
+ const streamState = createStreamState();
668
+ const chunk = {
669
+ candidates: [
670
+ {
671
+ content: {
672
+ parts: [{ functionCall: { id: 'tool-1', name: 'get_weather', args: { city: 'NYC' } } }],
673
+ },
674
+ },
675
+ ],
676
+ };
677
+ const events = mapChunkToEvents(chunk, streamState);
678
+ expect(events).toEqual([
679
+ {
680
+ type: 'modelContentBlockStartEvent',
681
+ start: { type: 'toolUseStart', name: 'get_weather', toolUseId: 'tool-1' },
682
+ },
683
+ {
684
+ type: 'modelContentBlockDeltaEvent',
685
+ delta: { type: 'toolUseInputDelta', input: '{"city":"NYC"}' },
686
+ },
687
+ { type: 'modelContentBlockStopEvent' },
688
+ ]);
689
+ expect(streamState.hasToolCalls).toBe(true);
690
+ });
691
+ it('generates tool use ID when Gemini does not provide one', () => {
692
+ const streamState = createStreamState();
693
+ const chunk = {
694
+ candidates: [
695
+ {
696
+ content: {
697
+ parts: [{ functionCall: { name: 'testTool', args: {} } }],
698
+ },
699
+ },
700
+ ],
701
+ };
702
+ const events = mapChunkToEvents(chunk, streamState);
703
+ const startEvent = events[0];
704
+ expect(startEvent.type).toBe('modelContentBlockStartEvent');
705
+ const start = startEvent.start;
706
+ expect(start.toolUseId).toMatch(/^tooluse_/);
707
+ });
708
+ it('includes reasoningSignature from thoughtSignature on function call', () => {
709
+ const streamState = createStreamState();
710
+ const chunk = {
711
+ candidates: [
712
+ {
713
+ content: {
714
+ parts: [
715
+ {
716
+ functionCall: { id: 'tool-1', name: 'testTool', args: {} },
717
+ thoughtSignature: 'sig-abc',
718
+ },
719
+ ],
720
+ },
721
+ },
722
+ ],
723
+ };
724
+ const events = mapChunkToEvents(chunk, streamState);
725
+ const startEvent = events[0];
726
+ const start = startEvent.start;
727
+ expect(start.reasoningSignature).toBe('sig-abc');
728
+ });
729
+ it('sets stop reason to toolUse when function calls are present', () => {
730
+ const streamState = createStreamState();
731
+ streamState.hasToolCalls = true;
732
+ const chunk = {
733
+ candidates: [{ finishReason: 'STOP' }],
734
+ };
735
+ const events = mapChunkToEvents(chunk, streamState);
736
+ expect(events).toEqual([{ type: 'modelMessageStopEvent', stopReason: 'toolUse' }]);
737
+ });
738
+ it.each([
739
+ { blockType: 'reasoning', stateField: 'reasoningContentBlockStarted' },
740
+ { blockType: 'text', stateField: 'textContentBlockStarted' },
741
+ ])('closes $blockType block before tool use block', ({ stateField }) => {
742
+ const streamState = createStreamState();
743
+ streamState[stateField] = true;
744
+ const chunk = {
745
+ candidates: [
746
+ {
747
+ content: {
748
+ parts: [{ functionCall: { id: 'tool-1', name: 'testTool', args: {} } }],
749
+ },
750
+ },
751
+ ],
752
+ };
753
+ const events = mapChunkToEvents(chunk, streamState);
754
+ expect(events[0]).toEqual({ type: 'modelContentBlockStopEvent' });
755
+ expect(events[1]).toEqual({
756
+ type: 'modelContentBlockStartEvent',
757
+ start: { type: 'toolUseStart', name: 'testTool', toolUseId: 'tool-1' },
758
+ });
759
+ expect(streamState[stateField]).toBe(false);
760
+ });
761
+ it('handles multiple function calls in a single response', () => {
762
+ const streamState = createStreamState();
763
+ const chunk = {
764
+ candidates: [
765
+ {
766
+ content: {
767
+ parts: [
768
+ { functionCall: { id: 'tool-1', name: 'get_weather', args: { city: 'NYC' } } },
769
+ { functionCall: { id: 'tool-2', name: 'get_time', args: { tz: 'EST' } } },
770
+ ],
771
+ },
772
+ },
773
+ ],
774
+ };
775
+ const events = mapChunkToEvents(chunk, streamState);
776
+ // Each function call: start + delta + stop = 3 events, x2 = 6
777
+ expect(events).toHaveLength(6);
778
+ expect(events[0]).toEqual({
779
+ type: 'modelContentBlockStartEvent',
780
+ start: { type: 'toolUseStart', name: 'get_weather', toolUseId: 'tool-1' },
781
+ });
782
+ expect(events[3]).toEqual({
783
+ type: 'modelContentBlockStartEvent',
784
+ start: { type: 'toolUseStart', name: 'get_time', toolUseId: 'tool-2' },
785
+ });
786
+ });
787
+ it('handles full tool use flow via stream method', async () => {
788
+ const { provider, messages } = setupStreamTest(async function* () {
789
+ yield {
790
+ candidates: [
791
+ {
792
+ content: {
793
+ parts: [{ functionCall: { id: 'call-1', name: 'get_weather', args: { city: 'NYC' } } }],
794
+ },
795
+ },
796
+ ],
797
+ };
798
+ yield { candidates: [{ finishReason: 'STOP' }] };
799
+ });
800
+ const events = await collectIterator(provider.stream(messages));
801
+ // messageStart, blockStart (toolUse), delta (toolUseInput), blockStop, messageStop
802
+ expect(events).toHaveLength(5);
803
+ expect(events[0]).toEqual({ type: 'modelMessageStartEvent', role: 'assistant' });
804
+ expect(events[1]).toEqual({
805
+ type: 'modelContentBlockStartEvent',
806
+ start: { type: 'toolUseStart', name: 'get_weather', toolUseId: 'call-1' },
807
+ });
808
+ expect(events[2]).toEqual({
809
+ type: 'modelContentBlockDeltaEvent',
810
+ delta: { type: 'toolUseInputDelta', input: '{"city":"NYC"}' },
811
+ });
812
+ expect(events[3]).toEqual({ type: 'modelContentBlockStopEvent' });
813
+ expect(events[4]).toEqual({ type: 'modelMessageStopEvent', stopReason: 'toolUse' });
517
814
  });
518
815
  });
519
816
  });