@strands-agents/sdk 0.2.1 → 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 (87) hide show
  1. package/dist/src/__fixtures__/mock-hook-provider.d.ts.map +1 -1
  2. package/dist/src/__fixtures__/mock-hook-provider.js +2 -1
  3. package/dist/src/__fixtures__/mock-hook-provider.js.map +1 -1
  4. package/dist/src/__tests__/errors.test.js +33 -1
  5. package/dist/src/__tests__/errors.test.js.map +1 -1
  6. package/dist/src/agent/__tests__/agent.hook.test.js +25 -23
  7. package/dist/src/agent/__tests__/agent.hook.test.js.map +1 -1
  8. package/dist/src/agent/agent.d.ts.map +1 -1
  9. package/dist/src/agent/agent.js +2 -1
  10. package/dist/src/agent/agent.js.map +1 -1
  11. package/dist/src/errors.d.ts +16 -0
  12. package/dist/src/errors.d.ts.map +1 -1
  13. package/dist/src/errors.js +19 -0
  14. package/dist/src/errors.js.map +1 -1
  15. package/dist/src/hooks/__tests__/events.test.js +18 -1
  16. package/dist/src/hooks/__tests__/events.test.js.map +1 -1
  17. package/dist/src/hooks/events.d.ts +11 -0
  18. package/dist/src/hooks/events.d.ts.map +1 -1
  19. package/dist/src/hooks/events.js +12 -0
  20. package/dist/src/hooks/events.js.map +1 -1
  21. package/dist/src/hooks/index.d.ts +1 -1
  22. package/dist/src/hooks/index.d.ts.map +1 -1
  23. package/dist/src/hooks/index.js +1 -1
  24. package/dist/src/hooks/index.js.map +1 -1
  25. package/dist/src/index.d.ts +2 -2
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +2 -2
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/models/__tests__/anthropic.test.d.ts +2 -0
  30. package/dist/src/models/__tests__/anthropic.test.d.ts.map +1 -0
  31. package/dist/src/models/__tests__/anthropic.test.js +481 -0
  32. package/dist/src/models/__tests__/anthropic.test.js.map +1 -0
  33. package/dist/src/models/__tests__/bedrock.test.js +86 -1
  34. package/dist/src/models/__tests__/bedrock.test.js.map +1 -1
  35. package/dist/src/models/__tests__/gemini.test.js +599 -62
  36. package/dist/src/models/__tests__/gemini.test.js.map +1 -1
  37. package/dist/src/models/__tests__/openai.test.js +104 -1
  38. package/dist/src/models/__tests__/openai.test.js.map +1 -1
  39. package/dist/src/models/anthropic.d.ts +28 -0
  40. package/dist/src/models/anthropic.d.ts.map +1 -0
  41. package/dist/src/models/anthropic.js +419 -0
  42. package/dist/src/models/anthropic.js.map +1 -0
  43. package/dist/src/models/bedrock.d.ts +6 -0
  44. package/dist/src/models/bedrock.d.ts.map +1 -1
  45. package/dist/src/models/bedrock.js +31 -4
  46. package/dist/src/models/bedrock.js.map +1 -1
  47. package/dist/src/models/gemini/adapters.d.ts +2 -1
  48. package/dist/src/models/gemini/adapters.d.ts.map +1 -1
  49. package/dist/src/models/gemini/adapters.js +259 -14
  50. package/dist/src/models/gemini/adapters.js.map +1 -1
  51. package/dist/src/models/gemini/model.d.ts.map +1 -1
  52. package/dist/src/models/gemini/model.js +38 -1
  53. package/dist/src/models/gemini/model.js.map +1 -1
  54. package/dist/src/models/gemini/types.d.ts +10 -1
  55. package/dist/src/models/gemini/types.d.ts.map +1 -1
  56. package/dist/src/models/model.d.ts.map +1 -1
  57. package/dist/src/models/model.js +4 -0
  58. package/dist/src/models/model.js.map +1 -1
  59. package/dist/src/models/openai.d.ts.map +1 -1
  60. package/dist/src/models/openai.js +20 -3
  61. package/dist/src/models/openai.js.map +1 -1
  62. package/dist/src/models/streaming.d.ts +5 -0
  63. package/dist/src/models/streaming.d.ts.map +1 -1
  64. package/dist/src/tsconfig.tsbuildinfo +1 -1
  65. package/dist/src/types/media.d.ts +1 -1
  66. package/dist/src/types/media.d.ts.map +1 -1
  67. package/dist/src/types/media.js +18 -4
  68. package/dist/src/types/media.js.map +1 -1
  69. package/dist/src/types/messages.d.ts +10 -0
  70. package/dist/src/types/messages.d.ts.map +1 -1
  71. package/dist/src/types/messages.js +8 -0
  72. package/dist/src/types/messages.js.map +1 -1
  73. package/dist/src/vended-tools/bash/__tests__/bash.test.node.d.ts +2 -0
  74. package/dist/src/vended-tools/bash/__tests__/bash.test.node.d.ts.map +1 -0
  75. package/dist/src/vended-tools/bash/__tests__/{bash.test.js → bash.test.node.js} +3 -4
  76. package/dist/src/vended-tools/bash/__tests__/bash.test.node.js.map +1 -0
  77. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.node.d.ts +2 -0
  78. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.node.d.ts.map +1 -0
  79. package/dist/src/vended-tools/file_editor/__tests__/{file-editor.test.js → file-editor.test.node.js} +1 -1
  80. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.node.js.map +1 -0
  81. package/package.json +11 -2
  82. package/dist/src/vended-tools/bash/__tests__/bash.test.d.ts +0 -2
  83. package/dist/src/vended-tools/bash/__tests__/bash.test.d.ts.map +0 -1
  84. package/dist/src/vended-tools/bash/__tests__/bash.test.js.map +0 -1
  85. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.d.ts +0 -2
  86. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.d.ts.map +0 -1
  87. package/dist/src/vended-tools/file_editor/__tests__/file-editor.test.js.map +0 -1
@@ -1,8 +1,11 @@
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, TextBlock, ToolResultBlock, ToolUseBlock, } from '../../types/messages.js';
7
+ import { formatMessages, mapChunkToEvents } from '../gemini/adapters.js';
8
+ import { ImageBlock, DocumentBlock, VideoBlock } from '../../types/media.js';
6
9
  /**
7
10
  * Helper to create a mock Gemini client with streaming support
8
11
  */
@@ -13,6 +16,49 @@ function createMockClient(streamGenerator) {
13
16
  },
14
17
  };
15
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
+ }
16
62
  describe('GeminiModel', () => {
17
63
  beforeEach(() => {
18
64
  vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
@@ -63,7 +109,7 @@ describe('GeminiModel', () => {
63
109
  await expect(collectIterator(provider.stream([]))).rejects.toThrow('At least one message is required');
64
110
  });
65
111
  it('emits message start and stop events', async () => {
66
- const mockClient = createMockClient(async function* () {
112
+ const { provider, messages } = setupStreamTest(async function* () {
67
113
  yield {
68
114
  candidates: [
69
115
  {
@@ -73,14 +119,12 @@ describe('GeminiModel', () => {
73
119
  };
74
120
  yield { candidates: [{ finishReason: 'STOP' }] };
75
121
  });
76
- const provider = new GeminiModel({ client: mockClient });
77
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
78
122
  const events = await collectIterator(provider.stream(messages));
79
123
  expect(events[0]).toEqual({ type: 'modelMessageStartEvent', role: 'assistant' });
80
124
  expect(events[events.length - 1]).toEqual({ type: 'modelMessageStopEvent', stopReason: 'endTurn' });
81
125
  });
82
126
  it('emits text content block events', async () => {
83
- const mockClient = createMockClient(async function* () {
127
+ const { provider, messages } = setupStreamTest(async function* () {
84
128
  yield {
85
129
  candidates: [
86
130
  {
@@ -97,8 +141,6 @@ describe('GeminiModel', () => {
97
141
  };
98
142
  yield { candidates: [{ finishReason: 'STOP' }] };
99
143
  });
100
- const provider = new GeminiModel({ client: mockClient });
101
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
102
144
  const events = await collectIterator(provider.stream(messages));
103
145
  expect(events).toHaveLength(6);
104
146
  expect(events[0]).toEqual({ type: 'modelMessageStartEvent', role: 'assistant' });
@@ -115,7 +157,7 @@ describe('GeminiModel', () => {
115
157
  expect(events[5]).toEqual({ type: 'modelMessageStopEvent', stopReason: 'endTurn' });
116
158
  });
117
159
  it('emits usage metadata when available', async () => {
118
- const mockClient = createMockClient(async function* () {
160
+ const { provider, messages } = setupStreamTest(async function* () {
119
161
  yield {
120
162
  candidates: [
121
163
  {
@@ -129,11 +171,8 @@ describe('GeminiModel', () => {
129
171
  };
130
172
  yield { candidates: [{ finishReason: 'STOP' }] };
131
173
  });
132
- const provider = new GeminiModel({ client: mockClient });
133
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
134
174
  const events = await collectIterator(provider.stream(messages));
135
175
  const metadataEvent = events.find((e) => e.type === 'modelMetadataEvent');
136
- expect(metadataEvent).toBeDefined();
137
176
  expect(metadataEvent).toEqual({
138
177
  type: 'modelMetadataEvent',
139
178
  usage: {
@@ -144,7 +183,7 @@ describe('GeminiModel', () => {
144
183
  });
145
184
  });
146
185
  it('handles MAX_TOKENS finish reason', async () => {
147
- const mockClient = createMockClient(async function* () {
186
+ const { provider, messages } = setupStreamTest(async function* () {
148
187
  yield {
149
188
  candidates: [
150
189
  {
@@ -154,8 +193,6 @@ describe('GeminiModel', () => {
154
193
  };
155
194
  yield { candidates: [{ finishReason: 'MAX_TOKENS' }] };
156
195
  });
157
- const provider = new GeminiModel({ client: mockClient });
158
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
159
196
  const events = await collectIterator(provider.stream(messages));
160
197
  const stopEvent = events.find((e) => e.type === 'modelMessageStopEvent');
161
198
  expect(stopEvent).toBeDefined();
@@ -194,81 +231,37 @@ describe('GeminiModel', () => {
194
231
  });
195
232
  });
196
233
  describe('system prompt', () => {
197
- /**
198
- * Helper to create a mock client that captures the request config
199
- */
200
- function createMockClientWithCapture(captureContainer) {
201
- return {
202
- models: {
203
- generateContentStream: vi.fn(async ({ config }) => {
204
- captureContainer.config = config;
205
- return (async function* () {
206
- yield { candidates: [{ finishReason: 'STOP' }] };
207
- })();
208
- }),
209
- },
210
- };
211
- }
212
234
  it('passes string system prompt to config', async () => {
213
- const captured = { config: null };
214
- const mockClient = createMockClientWithCapture(captured);
215
- const provider = new GeminiModel({ client: mockClient });
216
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
235
+ const { provider, captured, messages } = setupCaptureTest();
217
236
  await collectIterator(provider.stream(messages, { systemPrompt: 'You are a helpful assistant' }));
218
- expect(captured.config).toBeDefined();
219
237
  const config = captured.config;
220
238
  expect(config.systemInstruction).toBe('You are a helpful assistant');
221
239
  });
222
240
  it('ignores empty string system prompt', async () => {
223
- const captured = { config: null };
224
- const mockClient = createMockClientWithCapture(captured);
225
- const provider = new GeminiModel({ client: mockClient });
226
- const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] }];
241
+ const { provider, captured, messages } = setupCaptureTest();
227
242
  await collectIterator(provider.stream(messages, { systemPrompt: ' ' }));
228
- expect(captured.config).toBeDefined();
229
243
  const config = captured.config;
230
244
  expect(config.systemInstruction).toBeUndefined();
231
245
  });
232
246
  });
233
247
  describe('message formatting', () => {
234
- /**
235
- * Helper to create a mock client that captures the request contents
236
- */
237
- function createMockClientWithCapture(captureContainer) {
238
- return {
239
- models: {
240
- generateContentStream: vi.fn(async ({ contents }) => {
241
- captureContainer.contents = contents;
242
- return (async function* () {
243
- yield { candidates: [{ finishReason: 'STOP' }] };
244
- })();
245
- }),
246
- },
247
- };
248
- }
249
248
  it('formats user messages correctly', async () => {
250
- const captured = { contents: null };
251
- const mockClient = createMockClientWithCapture(captured);
252
- const provider = new GeminiModel({ client: mockClient });
249
+ const { provider, captured } = setupCaptureTest();
253
250
  const messages = [{ type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hello' }] }];
254
251
  await collectIterator(provider.stream(messages));
255
- expect(captured.contents).toBeDefined();
256
252
  const contents = captured.contents;
257
253
  expect(contents).toHaveLength(1);
258
254
  expect(contents[0]?.role).toBe('user');
259
255
  expect(contents[0]?.parts[0]?.text).toBe('Hello');
260
256
  });
261
257
  it('formats assistant messages correctly', async () => {
262
- const captured = { contents: null };
263
- const mockClient = createMockClientWithCapture(captured);
264
- const provider = new GeminiModel({ client: mockClient });
258
+ const { provider, captured } = setupCaptureTest();
265
259
  const messages = [
266
260
  { type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'Hi' }] },
267
261
  { type: 'message', role: 'assistant', content: [{ type: 'textBlock', text: 'Hello!' }] },
268
262
  { type: 'message', role: 'user', content: [{ type: 'textBlock', text: 'How are you?' }] },
269
263
  ];
270
264
  await collectIterator(provider.stream(messages));
271
- expect(captured.contents).toBeDefined();
272
265
  const contents = captured.contents;
273
266
  expect(contents).toHaveLength(3);
274
267
  expect(contents[0]?.role).toBe('user');
@@ -276,5 +269,549 @@ describe('GeminiModel', () => {
276
269
  expect(contents[2]?.role).toBe('user');
277
270
  });
278
271
  });
272
+ describe('content type formatting', () => {
273
+ describe('image content', () => {
274
+ it('formats image with bytes source as inlineData', () => {
275
+ const imageBlock = new ImageBlock({
276
+ format: 'png',
277
+ source: { bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]) },
278
+ });
279
+ const contents = formatBlock(imageBlock);
280
+ expect(contents).toHaveLength(1);
281
+ expect(contents[0].parts).toEqual([{ inlineData: { data: 'iVBORw==', mimeType: 'image/png' } }]);
282
+ });
283
+ it('formats image with URL source as fileData', () => {
284
+ const imageBlock = new ImageBlock({
285
+ format: 'jpeg',
286
+ source: { url: 'https://example.com/image.jpg' },
287
+ });
288
+ const contents = formatBlock(imageBlock);
289
+ expect(contents).toHaveLength(1);
290
+ expect(contents[0].parts).toEqual([
291
+ { fileData: { fileUri: 'https://example.com/image.jpg', mimeType: 'image/jpeg' } },
292
+ ]);
293
+ });
294
+ it('skips image with S3 source and logs warning', () => {
295
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
296
+ const imageBlock = new ImageBlock({
297
+ format: 'png',
298
+ source: { s3Location: { uri: 's3://test/image.png' } },
299
+ });
300
+ const contents = formatBlock(imageBlock);
301
+ // Message with no valid parts is not included
302
+ expect(contents).toHaveLength(0);
303
+ expect(warnSpy).toHaveBeenCalled();
304
+ warnSpy.mockRestore();
305
+ });
306
+ });
307
+ describe('document content', () => {
308
+ it('formats document with bytes source as inlineData', () => {
309
+ const docBlock = new DocumentBlock({
310
+ name: 'test.pdf',
311
+ format: 'pdf',
312
+ source: { bytes: new Uint8Array([0x25, 0x50, 0x44, 0x46]) },
313
+ });
314
+ const contents = formatBlock(docBlock);
315
+ expect(contents).toHaveLength(1);
316
+ expect(contents[0].parts).toEqual([{ inlineData: { data: 'JVBERg==', mimeType: 'application/pdf' } }]);
317
+ });
318
+ it('formats document with text source as inlineData bytes', () => {
319
+ const docBlock = new DocumentBlock({
320
+ name: 'test.txt',
321
+ format: 'txt',
322
+ source: { text: 'Document content here' },
323
+ });
324
+ const contents = formatBlock(docBlock);
325
+ expect(contents).toHaveLength(1);
326
+ expect(contents[0].parts).toEqual([
327
+ { inlineData: { data: 'RG9jdW1lbnQgY29udGVudCBoZXJl', mimeType: 'text/plain' } },
328
+ ]);
329
+ });
330
+ it('formats document with content block source as separate text parts', () => {
331
+ const docBlock = new DocumentBlock({
332
+ name: 'test.txt',
333
+ format: 'txt',
334
+ source: { content: [{ text: 'Line 1' }, { text: 'Line 2' }] },
335
+ });
336
+ const contents = formatBlock(docBlock);
337
+ expect(contents).toHaveLength(1);
338
+ expect(contents[0].parts).toEqual([{ text: 'Line 1' }, { text: 'Line 2' }]);
339
+ });
340
+ });
341
+ describe('video content', () => {
342
+ it('formats video with bytes source as inlineData', () => {
343
+ const videoBlock = new VideoBlock({
344
+ format: 'mp4',
345
+ source: { bytes: new Uint8Array([0x00, 0x00, 0x00, 0x1c]) },
346
+ });
347
+ const contents = formatBlock(videoBlock);
348
+ expect(contents).toHaveLength(1);
349
+ expect(contents[0].parts).toEqual([{ inlineData: { data: 'AAAAHA==', mimeType: 'video/mp4' } }]);
350
+ });
351
+ });
352
+ describe('reasoning content', () => {
353
+ it('formats reasoning block with thought flag', () => {
354
+ const reasoningBlock = new ReasoningBlock({ text: 'Let me think about this...' });
355
+ const contents = formatBlock(reasoningBlock, 'assistant');
356
+ expect(contents).toHaveLength(1);
357
+ expect(contents[0].parts).toEqual([{ text: 'Let me think about this...', thought: true }]);
358
+ });
359
+ it('includes thought signature when present', () => {
360
+ const reasoningBlock = new ReasoningBlock({ text: 'Thinking...', signature: 'sig123' });
361
+ const contents = formatBlock(reasoningBlock, 'assistant');
362
+ expect(contents).toHaveLength(1);
363
+ expect(contents[0].parts).toEqual([{ text: 'Thinking...', thought: true, thoughtSignature: 'sig123' }]);
364
+ });
365
+ it('skips reasoning block with empty text', () => {
366
+ const reasoningBlock = new ReasoningBlock({ text: '' });
367
+ const contents = formatBlock(reasoningBlock, 'assistant');
368
+ expect(contents).toHaveLength(0);
369
+ });
370
+ });
371
+ describe('unsupported content types', () => {
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 }) => {
379
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
380
+ const contents = formatBlock(block);
381
+ expect(contents).toHaveLength(0);
382
+ warnSpy.mockRestore();
383
+ });
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
+ ]);
391
+ });
392
+ });
393
+ });
394
+ describe('reasoning content streaming', () => {
395
+ it('emits reasoning content delta events for thought parts', async () => {
396
+ const { provider, messages } = setupStreamTest(async function* () {
397
+ yield {
398
+ candidates: [
399
+ {
400
+ content: { parts: [{ text: 'Thinking...', thought: true }] },
401
+ },
402
+ ],
403
+ };
404
+ yield { candidates: [{ finishReason: 'STOP' }] };
405
+ });
406
+ const events = await collectIterator(provider.stream(messages));
407
+ expect(events).toHaveLength(5);
408
+ expect(events[0]).toEqual({ type: 'modelMessageStartEvent', role: 'assistant' });
409
+ expect(events[1]).toEqual({ type: 'modelContentBlockStartEvent' });
410
+ expect(events[2]).toEqual({
411
+ type: 'modelContentBlockDeltaEvent',
412
+ delta: { type: 'reasoningContentDelta', text: 'Thinking...' },
413
+ });
414
+ expect(events[3]).toEqual({ type: 'modelContentBlockStopEvent' });
415
+ expect(events[4]).toEqual({ type: 'modelMessageStopEvent', stopReason: 'endTurn' });
416
+ });
417
+ it('handles transition from reasoning to text content', async () => {
418
+ const { provider, messages } = setupStreamTest(async function* () {
419
+ yield {
420
+ candidates: [
421
+ {
422
+ content: { parts: [{ text: 'Let me think...', thought: true }] },
423
+ },
424
+ ],
425
+ };
426
+ yield {
427
+ candidates: [
428
+ {
429
+ content: { parts: [{ text: 'Here is my answer' }] },
430
+ },
431
+ ],
432
+ };
433
+ yield { candidates: [{ finishReason: 'STOP' }] };
434
+ });
435
+ const events = await collectIterator(provider.stream(messages));
436
+ // Should have: messageStart, blockStart (reasoning), delta (reasoning), blockStop,
437
+ // blockStart (text), delta (text), blockStop, messageStop
438
+ expect(events).toHaveLength(8);
439
+ // Reasoning block
440
+ expect(events[1]).toEqual({ type: 'modelContentBlockStartEvent' });
441
+ expect(events[2]).toEqual({
442
+ type: 'modelContentBlockDeltaEvent',
443
+ delta: { type: 'reasoningContentDelta', text: 'Let me think...' },
444
+ });
445
+ expect(events[3]).toEqual({ type: 'modelContentBlockStopEvent' });
446
+ // Text block
447
+ expect(events[4]).toEqual({ type: 'modelContentBlockStartEvent' });
448
+ expect(events[5]).toEqual({
449
+ type: 'modelContentBlockDeltaEvent',
450
+ delta: { type: 'textDelta', text: 'Here is my answer' },
451
+ });
452
+ expect(events[6]).toEqual({ type: 'modelContentBlockStopEvent' });
453
+ expect(events[7]).toEqual({ type: 'modelMessageStopEvent', stopReason: 'endTurn' });
454
+ });
455
+ it('includes signature in reasoning delta when present', async () => {
456
+ const { provider, messages } = setupStreamTest(async function* () {
457
+ yield {
458
+ candidates: [
459
+ {
460
+ content: {
461
+ parts: [
462
+ {
463
+ text: 'Thinking...',
464
+ thought: true,
465
+ thoughtSignature: 'sig456',
466
+ },
467
+ ],
468
+ },
469
+ },
470
+ ],
471
+ };
472
+ yield { candidates: [{ finishReason: 'STOP' }] };
473
+ });
474
+ const events = await collectIterator(provider.stream(messages));
475
+ const deltaEvent = events.find((e) => e.type === 'modelContentBlockDeltaEvent' && e.delta.type === 'reasoningContentDelta');
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' });
814
+ });
815
+ });
279
816
  });
280
817
  //# sourceMappingURL=gemini.test.js.map