@boostecom/provider 0.0.1

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 (70) hide show
  1. package/README.md +90 -0
  2. package/dist/index.cjs +2522 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +848 -0
  5. package/dist/index.d.ts +848 -0
  6. package/dist/index.js +2484 -0
  7. package/dist/index.js.map +1 -0
  8. package/docs/content/README.md +337 -0
  9. package/docs/content/agent-teams.mdx +324 -0
  10. package/docs/content/api.mdx +757 -0
  11. package/docs/content/best-practices.mdx +624 -0
  12. package/docs/content/examples.mdx +675 -0
  13. package/docs/content/guide.mdx +516 -0
  14. package/docs/content/index.mdx +99 -0
  15. package/docs/content/installation.mdx +246 -0
  16. package/docs/content/skills.mdx +548 -0
  17. package/docs/content/troubleshooting.mdx +588 -0
  18. package/docs/examples/README.md +499 -0
  19. package/docs/examples/abort-signal.ts +125 -0
  20. package/docs/examples/agent-teams.ts +122 -0
  21. package/docs/examples/basic-usage.ts +73 -0
  22. package/docs/examples/check-cli.ts +51 -0
  23. package/docs/examples/conversation-history.ts +69 -0
  24. package/docs/examples/custom-config.ts +90 -0
  25. package/docs/examples/generate-object-constraints.ts +209 -0
  26. package/docs/examples/generate-object.ts +211 -0
  27. package/docs/examples/hooks-callbacks.ts +63 -0
  28. package/docs/examples/images.ts +76 -0
  29. package/docs/examples/integration-test.ts +241 -0
  30. package/docs/examples/limitations.ts +150 -0
  31. package/docs/examples/logging-custom-logger.ts +99 -0
  32. package/docs/examples/logging-default.ts +55 -0
  33. package/docs/examples/logging-disabled.ts +74 -0
  34. package/docs/examples/logging-verbose.ts +64 -0
  35. package/docs/examples/long-running-tasks.ts +179 -0
  36. package/docs/examples/message-injection.ts +210 -0
  37. package/docs/examples/mid-stream-injection.ts +126 -0
  38. package/docs/examples/run-all-examples.sh +48 -0
  39. package/docs/examples/sdk-tools-callbacks.ts +49 -0
  40. package/docs/examples/skills-discovery.ts +144 -0
  41. package/docs/examples/skills-management.ts +140 -0
  42. package/docs/examples/stream-object.ts +80 -0
  43. package/docs/examples/streaming.ts +52 -0
  44. package/docs/examples/structured-output-repro.ts +227 -0
  45. package/docs/examples/tool-management.ts +215 -0
  46. package/docs/examples/tool-streaming.ts +132 -0
  47. package/docs/examples/zod4-compatibility-test.ts +290 -0
  48. package/docs/src/claude-code-language-model.test.ts +3883 -0
  49. package/docs/src/claude-code-language-model.ts +2586 -0
  50. package/docs/src/claude-code-provider.test.ts +97 -0
  51. package/docs/src/claude-code-provider.ts +179 -0
  52. package/docs/src/convert-to-claude-code-messages.images.test.ts +104 -0
  53. package/docs/src/convert-to-claude-code-messages.test.ts +193 -0
  54. package/docs/src/convert-to-claude-code-messages.ts +419 -0
  55. package/docs/src/errors.test.ts +213 -0
  56. package/docs/src/errors.ts +216 -0
  57. package/docs/src/index.test.ts +49 -0
  58. package/docs/src/index.ts +98 -0
  59. package/docs/src/logger.integration.test.ts +164 -0
  60. package/docs/src/logger.test.ts +184 -0
  61. package/docs/src/logger.ts +65 -0
  62. package/docs/src/map-claude-code-finish-reason.test.ts +120 -0
  63. package/docs/src/map-claude-code-finish-reason.ts +60 -0
  64. package/docs/src/mcp-helpers.test.ts +71 -0
  65. package/docs/src/mcp-helpers.ts +123 -0
  66. package/docs/src/message-injection.test.ts +460 -0
  67. package/docs/src/types.ts +447 -0
  68. package/docs/src/validation.test.ts +558 -0
  69. package/docs/src/validation.ts +360 -0
  70. package/package.json +124 -0
@@ -0,0 +1,3883 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { ClaudeCodeLanguageModel } from './claude-code-language-model.js';
3
+ import { getErrorMetadata, isAuthenticationError } from './errors.js';
4
+ import type { LanguageModelV3StreamPart } from '@ai-sdk/provider';
5
+
6
+ // Extend stream part union locally to include provider-specific 'tool-error'
7
+ type ToolErrorPart = {
8
+ type: 'tool-error';
9
+ toolCallId: string;
10
+ toolName: string;
11
+ error: string;
12
+ providerExecuted: true;
13
+ providerMetadata?: Record<string, unknown>;
14
+ };
15
+ type ExtendedStreamPart = LanguageModelV3StreamPart | ToolErrorPart;
16
+
17
+ // Mock the SDK module with factory function
18
+ vi.mock('@anthropic-ai/claude-agent-sdk', () => {
19
+ return {
20
+ query: vi.fn(),
21
+ // Note: real SDK may not export AbortError at runtime; test mock provides it
22
+ AbortError: class AbortError extends Error {
23
+ constructor(message?: string) {
24
+ super(message);
25
+ this.name = 'AbortError';
26
+ }
27
+ },
28
+ };
29
+ });
30
+
31
+ // Import the mocked module to get typed references
32
+ import { query as mockQuery, AbortError as MockAbortError } from '@anthropic-ai/claude-agent-sdk';
33
+ import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
34
+
35
+ const STREAMING_WARNING_MESSAGE =
36
+ "Claude Agent SDK features (hooks/MCP/images) require streaming input. Set `streamingInput: 'always'` or provide `canUseTool` (auto streams only when canUseTool is set).";
37
+
38
+ describe('ClaudeCodeLanguageModel', () => {
39
+ let model: ClaudeCodeLanguageModel;
40
+
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+
44
+ model = new ClaudeCodeLanguageModel({
45
+ id: 'sonnet',
46
+ settings: {},
47
+ });
48
+ });
49
+
50
+ describe('doGenerate', () => {
51
+ it('invokes onQueryCreated with the query response', async () => {
52
+ const onQueryCreated = vi.fn();
53
+ const modelWithHook = new ClaudeCodeLanguageModel({
54
+ id: 'sonnet',
55
+ settings: { onQueryCreated } as any,
56
+ });
57
+
58
+ const mockResponse = {
59
+ async *[Symbol.asyncIterator]() {
60
+ yield {
61
+ type: 'result',
62
+ subtype: 'success',
63
+ session_id: 's-onquery',
64
+ usage: { input_tokens: 0, output_tokens: 0 },
65
+ };
66
+ },
67
+ };
68
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
69
+
70
+ await modelWithHook.doGenerate({
71
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
72
+ } as any);
73
+
74
+ expect(onQueryCreated).toHaveBeenCalledTimes(1);
75
+ expect(onQueryCreated).toHaveBeenCalledWith(mockResponse);
76
+ });
77
+
78
+ it('uses AsyncIterable prompt when streamingInput auto and canUseTool provided', async () => {
79
+ const hooks = {} as any;
80
+ const canUseTool = async () => ({ behavior: 'allow', updatedInput: {} });
81
+ const modelWithStream = new ClaudeCodeLanguageModel({
82
+ id: 'sonnet',
83
+ settings: { hooks, canUseTool, streamingInput: 'auto' } as any,
84
+ });
85
+
86
+ const mockResponse = {
87
+ async *[Symbol.asyncIterator]() {
88
+ yield {
89
+ type: 'result',
90
+ subtype: 'success',
91
+ session_id: 's2',
92
+ usage: { input_tokens: 0, output_tokens: 0 },
93
+ };
94
+ },
95
+ };
96
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
97
+
98
+ await modelWithStream.doGenerate({
99
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
100
+ } as any);
101
+
102
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any;
103
+ expect(call).toBeDefined();
104
+ // AsyncIterable check
105
+ expect(typeof call.prompt?.[Symbol.asyncIterator]).toBe('function');
106
+ });
107
+
108
+ it('includes image content in streaming prompts when enabled', async () => {
109
+ const modelWithImages = new ClaudeCodeLanguageModel({
110
+ id: 'sonnet',
111
+ settings: { streamingInput: 'always' } as any,
112
+ });
113
+
114
+ const mockResponse = {
115
+ async *[Symbol.asyncIterator]() {
116
+ yield {
117
+ type: 'result',
118
+ subtype: 'success',
119
+ session_id: 'img-session',
120
+ usage: { input_tokens: 0, output_tokens: 0 },
121
+ };
122
+ },
123
+ };
124
+
125
+ let promptContentPromise: Promise<any> | undefined;
126
+
127
+ const isAsyncIterable = (value: unknown): value is AsyncIterable<unknown> => {
128
+ return Boolean(
129
+ value &&
130
+ typeof value === 'object' &&
131
+ Symbol.asyncIterator in value &&
132
+ typeof (value as Record<PropertyKey, unknown>)[Symbol.asyncIterator] === 'function'
133
+ );
134
+ };
135
+
136
+ vi.mocked(mockQuery).mockImplementation(({ prompt }) => {
137
+ if (isAsyncIterable(prompt)) {
138
+ const iterator = prompt[Symbol.asyncIterator]();
139
+ promptContentPromise = iterator
140
+ .next()
141
+ .then(({ value }) => (value as SDKUserMessage | undefined)?.message?.content);
142
+ }
143
+ return mockResponse as any;
144
+ });
145
+
146
+ await modelWithImages.doGenerate({
147
+ prompt: [
148
+ {
149
+ role: 'user',
150
+ content: [
151
+ { type: 'text', text: 'Describe this image.' },
152
+ { type: 'image', image: 'data:image/png;base64,aGVsbG8=' },
153
+ ],
154
+ },
155
+ ],
156
+ } as any);
157
+
158
+ expect(promptContentPromise).toBeDefined();
159
+ const content = await promptContentPromise!;
160
+ expect(Array.isArray(content)).toBe(true);
161
+ expect(content).toHaveLength(2);
162
+ expect(content[0]).toEqual({ type: 'text', text: 'Human: Describe this image.' });
163
+ expect(content[1]).toEqual({
164
+ type: 'image',
165
+ source: {
166
+ type: 'base64',
167
+ media_type: 'image/png',
168
+ data: 'aGVsbG8=',
169
+ },
170
+ });
171
+ });
172
+
173
+ it('keeps string prompt when streamingInput off even if canUseTool provided', async () => {
174
+ const modelWithOff = new ClaudeCodeLanguageModel({
175
+ id: 'sonnet',
176
+ settings: {
177
+ canUseTool: async () => ({ behavior: 'allow', updatedInput: {} }),
178
+ streamingInput: 'off',
179
+ } as any,
180
+ });
181
+ const mockResponse = {
182
+ async *[Symbol.asyncIterator]() {
183
+ yield {
184
+ type: 'result',
185
+ subtype: 'success',
186
+ session_id: 's3',
187
+ usage: { input_tokens: 0, output_tokens: 0 },
188
+ };
189
+ },
190
+ };
191
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
192
+
193
+ await modelWithOff.doGenerate({
194
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
195
+ } as any);
196
+
197
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0];
198
+ expect(typeof call.prompt).toBe('string');
199
+ });
200
+
201
+ it('throws when canUseTool is combined with permissionPromptToolName', async () => {
202
+ const model = new ClaudeCodeLanguageModel({
203
+ id: 'sonnet',
204
+ settings: {
205
+ canUseTool: async () => ({ behavior: 'allow', updatedInput: {} }),
206
+ permissionPromptToolName: 'stdio',
207
+ streamingInput: 'auto',
208
+ } as any,
209
+ });
210
+
211
+ const promise = model.doGenerate({
212
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
213
+ } as any);
214
+ await expect(promise).rejects.toThrow(/cannot be used with permissionPromptToolName/);
215
+ });
216
+ it('should pass through hooks and canUseTool to SDK query options', async () => {
217
+ const preToolHook = async () => ({ continue: true });
218
+ const hooks = { PreToolUse: [{ hooks: [preToolHook] }] } as any;
219
+ const canUseTool = async () => ({ behavior: 'allow', updatedInput: {} });
220
+
221
+ const modelWithCallbacks = new ClaudeCodeLanguageModel({
222
+ id: 'sonnet',
223
+ settings: { hooks, canUseTool } as any,
224
+ });
225
+
226
+ const mockResponse = {
227
+ async *[Symbol.asyncIterator]() {
228
+ yield {
229
+ type: 'result',
230
+ subtype: 'success',
231
+ session_id: 's1',
232
+ usage: { input_tokens: 0, output_tokens: 0 },
233
+ };
234
+ },
235
+ };
236
+
237
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
238
+
239
+ await modelWithCallbacks.doGenerate({
240
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
241
+ } as any);
242
+
243
+ expect(vi.mocked(mockQuery)).toHaveBeenCalled();
244
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0];
245
+ expect(call?.options?.hooks).toBe(hooks);
246
+ expect(call?.options?.canUseTool).toBe(canUseTool);
247
+ });
248
+
249
+ it('should merge base env with settings.env and allow undefined values', async () => {
250
+ const originalMerge = process.env.C2_TEST_MERGE;
251
+ const originalOverride = process.env.C2_TEST_OVERRIDE;
252
+ const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
253
+ try {
254
+ process.env.C2_TEST_MERGE = 'from-process';
255
+ process.env.C2_TEST_OVERRIDE = 'original';
256
+ process.env.CLAUDE_CONFIG_DIR = 'from-process';
257
+
258
+ const modelWithEnv = new ClaudeCodeLanguageModel({
259
+ id: 'sonnet',
260
+ settings: {
261
+ env: {
262
+ CUSTOM_ENV: 'custom',
263
+ C2_TEST_OVERRIDE: 'override',
264
+ C2_TEST_UNDEF: undefined,
265
+ },
266
+ } as any,
267
+ });
268
+
269
+ const mockResponse = {
270
+ async *[Symbol.asyncIterator]() {
271
+ yield {
272
+ type: 'result',
273
+ subtype: 'success',
274
+ session_id: 's-env',
275
+ usage: { input_tokens: 0, output_tokens: 0 },
276
+ };
277
+ },
278
+ };
279
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
280
+
281
+ await modelWithEnv.doGenerate({
282
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
283
+ } as any);
284
+
285
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any;
286
+ expect(call).toBeDefined();
287
+ expect(call.options).toBeDefined();
288
+ expect(call.options.env).toBeDefined();
289
+ // Provided vars
290
+ expect(call.options.env.CUSTOM_ENV).toBe('custom');
291
+ expect(call.options.env.C2_TEST_OVERRIDE).toBe('override');
292
+ // Whitelisted from process.env
293
+ expect(call.options.env.CLAUDE_CONFIG_DIR).toBe('from-process');
294
+ expect('C2_TEST_MERGE' in call.options.env).toBe(false);
295
+ // Undefined values are preserved (key exists with undefined)
296
+ expect('C2_TEST_UNDEF' in call.options.env).toBe(true);
297
+ expect(call.options.env.C2_TEST_UNDEF).toBeUndefined();
298
+ } finally {
299
+ if (originalMerge === undefined) {
300
+ delete process.env.C2_TEST_MERGE;
301
+ } else {
302
+ process.env.C2_TEST_MERGE = originalMerge;
303
+ }
304
+ if (originalOverride === undefined) {
305
+ delete process.env.C2_TEST_OVERRIDE;
306
+ } else {
307
+ process.env.C2_TEST_OVERRIDE = originalOverride;
308
+ }
309
+ if (originalClaudeConfigDir === undefined) {
310
+ delete process.env.CLAUDE_CONFIG_DIR;
311
+ } else {
312
+ process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
313
+ }
314
+ }
315
+ });
316
+
317
+ it('should omit env in SDK options when settings.env is undefined', async () => {
318
+ const modelNoEnv = new ClaudeCodeLanguageModel({
319
+ id: 'sonnet',
320
+ settings: {},
321
+ });
322
+
323
+ const mockResponse = {
324
+ async *[Symbol.asyncIterator]() {
325
+ yield {
326
+ type: 'result',
327
+ subtype: 'success',
328
+ session_id: 's-noenv',
329
+ usage: { input_tokens: 0, output_tokens: 0 },
330
+ };
331
+ },
332
+ };
333
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
334
+
335
+ await modelNoEnv.doGenerate({
336
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
337
+ } as any);
338
+
339
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any;
340
+ expect(call).toBeDefined();
341
+ expect(call.options).toBeDefined();
342
+ expect(call.options.env).toBeUndefined();
343
+ });
344
+
345
+ it('should pass through Agent SDK options and allow sdkOptions overrides', async () => {
346
+ const modelWithSdkOptions = new ClaudeCodeLanguageModel({
347
+ id: 'sonnet',
348
+ settings: {
349
+ maxTurns: 5,
350
+ betas: ['context-1m-2025-08-07'],
351
+ enableFileCheckpointing: true,
352
+ maxBudgetUsd: 2,
353
+ plugins: [{ type: 'local', path: './plugins/example' }],
354
+ resumeSessionAt: 'message-uuid',
355
+ sandbox: { enabled: true },
356
+ tools: ['Read'],
357
+ sdkOptions: {
358
+ maxTurns: 9,
359
+ allowDangerouslySkipPermissions: true,
360
+ },
361
+ } as any,
362
+ });
363
+
364
+ const mockResponse = {
365
+ async *[Symbol.asyncIterator]() {
366
+ yield {
367
+ type: 'result',
368
+ subtype: 'success',
369
+ session_id: 's-sdk',
370
+ usage: { input_tokens: 0, output_tokens: 0 },
371
+ };
372
+ },
373
+ };
374
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
375
+
376
+ await modelWithSdkOptions.doGenerate({
377
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
378
+ } as any);
379
+
380
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any;
381
+ expect(call?.options?.maxTurns).toBe(9);
382
+ expect(call?.options?.betas).toEqual(['context-1m-2025-08-07']);
383
+ expect(call?.options?.enableFileCheckpointing).toBe(true);
384
+ expect(call?.options?.maxBudgetUsd).toBe(2);
385
+ expect(call?.options?.plugins).toEqual([{ type: 'local', path: './plugins/example' }]);
386
+ expect(call?.options?.resumeSessionAt).toBe('message-uuid');
387
+ expect(call?.options?.sandbox).toEqual({ enabled: true });
388
+ expect(call?.options?.tools).toEqual(['Read']);
389
+ expect(call?.options?.allowDangerouslySkipPermissions).toBe(true);
390
+ });
391
+
392
+ it('should pass through persistSession option', async () => {
393
+ const modelWithPersist = new ClaudeCodeLanguageModel({
394
+ id: 'sonnet',
395
+ settings: {
396
+ persistSession: false,
397
+ },
398
+ });
399
+
400
+ const mockResponse = {
401
+ async *[Symbol.asyncIterator]() {
402
+ yield {
403
+ type: 'result',
404
+ subtype: 'success',
405
+ session_id: 's-persist',
406
+ usage: { input_tokens: 0, output_tokens: 0 },
407
+ };
408
+ },
409
+ };
410
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
411
+
412
+ await modelWithPersist.doGenerate({
413
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
414
+ } as any);
415
+
416
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any;
417
+ expect(call?.options?.persistSession).toBe(false);
418
+ });
419
+
420
+ it('should pass through sessionId option', async () => {
421
+ const modelWithSessionId = new ClaudeCodeLanguageModel({
422
+ id: 'sonnet',
423
+ settings: {
424
+ sessionId: 'custom-session-123',
425
+ } as any,
426
+ });
427
+
428
+ const mockResponse = {
429
+ async *[Symbol.asyncIterator]() {
430
+ yield {
431
+ type: 'result',
432
+ subtype: 'success',
433
+ session_id: 'custom-session-123',
434
+ usage: { input_tokens: 0, output_tokens: 0 },
435
+ };
436
+ },
437
+ };
438
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
439
+
440
+ await modelWithSessionId.doGenerate({
441
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
442
+ } as any);
443
+
444
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any;
445
+ expect(call?.options?.sessionId).toBe('custom-session-123');
446
+ });
447
+
448
+ it('should pass through debug and debugFile options', async () => {
449
+ const modelWithDebug = new ClaudeCodeLanguageModel({
450
+ id: 'sonnet',
451
+ settings: {
452
+ debug: true,
453
+ debugFile: '/tmp/debug.log',
454
+ } as any,
455
+ });
456
+
457
+ const mockResponse = {
458
+ async *[Symbol.asyncIterator]() {
459
+ yield {
460
+ type: 'result',
461
+ subtype: 'success',
462
+ session_id: 's-debug',
463
+ usage: { input_tokens: 0, output_tokens: 0 },
464
+ };
465
+ },
466
+ };
467
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
468
+
469
+ await modelWithDebug.doGenerate({
470
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
471
+ } as any);
472
+
473
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any;
474
+ expect(call?.options?.debug).toBe(true);
475
+ expect(call?.options?.debugFile).toBe('/tmp/debug.log');
476
+ });
477
+
478
+ it('should use stop_reason from result message for finish reason', async () => {
479
+ const mockResponse = {
480
+ async *[Symbol.asyncIterator]() {
481
+ yield {
482
+ type: 'result',
483
+ subtype: 'success',
484
+ session_id: 's-stop-reason',
485
+ usage: { input_tokens: 10, output_tokens: 5 },
486
+ stop_reason: 'end_turn',
487
+ };
488
+ },
489
+ };
490
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
491
+
492
+ const result = await model.doGenerate({
493
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
494
+ } as any);
495
+
496
+ expect(result.finishReason).toEqual({ unified: 'stop', raw: 'end_turn' });
497
+ });
498
+
499
+ it('should pass through spawnClaudeCodeProcess option', async () => {
500
+ const customSpawner = vi.fn();
501
+ const modelWithSpawner = new ClaudeCodeLanguageModel({
502
+ id: 'sonnet',
503
+ settings: {
504
+ spawnClaudeCodeProcess: customSpawner,
505
+ } as any,
506
+ });
507
+
508
+ const mockResponse = {
509
+ async *[Symbol.asyncIterator]() {
510
+ yield {
511
+ type: 'result',
512
+ subtype: 'success',
513
+ session_id: 's-spawn',
514
+ usage: { input_tokens: 0, output_tokens: 0 },
515
+ };
516
+ },
517
+ };
518
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
519
+
520
+ await modelWithSpawner.doGenerate({
521
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
522
+ } as any);
523
+
524
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any;
525
+ expect(call?.options?.spawnClaudeCodeProcess).toBe(customSpawner);
526
+ });
527
+
528
+ it('should sync sdkOptions.resume with streaming prompt session_id', async () => {
529
+ const modelWithResume = new ClaudeCodeLanguageModel({
530
+ id: 'sonnet',
531
+ settings: {
532
+ streamingInput: 'always',
533
+ resume: 'settings-session',
534
+ sdkOptions: { resume: 'sdk-session' },
535
+ } as any,
536
+ });
537
+
538
+ const mockResponse = {
539
+ async *[Symbol.asyncIterator]() {
540
+ yield {
541
+ type: 'result',
542
+ subtype: 'success',
543
+ session_id: 'sdk-session',
544
+ usage: { input_tokens: 0, output_tokens: 0 },
545
+ };
546
+ },
547
+ };
548
+
549
+ let promptSessionId: string | undefined;
550
+ let promptSessionPromise: Promise<void> | undefined;
551
+ vi.mocked(mockQuery).mockImplementation(({ prompt }) => {
552
+ const iterator =
553
+ prompt && typeof (prompt as any)[Symbol.asyncIterator] === 'function'
554
+ ? (prompt as AsyncIterable<SDKUserMessage>)[Symbol.asyncIterator]()
555
+ : undefined;
556
+ if (iterator) {
557
+ promptSessionPromise = iterator.next().then(({ value }) => {
558
+ promptSessionId = value?.session_id;
559
+ });
560
+ }
561
+ return mockResponse as any;
562
+ });
563
+
564
+ await modelWithResume.doGenerate({
565
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
566
+ } as any);
567
+
568
+ if (promptSessionPromise) {
569
+ await promptSessionPromise;
570
+ }
571
+
572
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any;
573
+ expect(call?.options?.resume).toBe('sdk-session');
574
+ expect(promptSessionId).toBe('sdk-session');
575
+ });
576
+
577
+ it('should ignore blocked sdkOptions fields', async () => {
578
+ const externalAbortController = new AbortController();
579
+ const modelWithBlocked = new ClaudeCodeLanguageModel({
580
+ id: 'sonnet',
581
+ settings: {
582
+ sdkOptions: {
583
+ model: 'override-model',
584
+ abortController: externalAbortController,
585
+ prompt: 'override-prompt',
586
+ outputFormat: { type: 'json_schema', schema: { foo: 'bar' } },
587
+ },
588
+ } as any,
589
+ });
590
+
591
+ const mockResponse = {
592
+ async *[Symbol.asyncIterator]() {
593
+ yield {
594
+ type: 'result',
595
+ subtype: 'success',
596
+ session_id: 's-blocked',
597
+ usage: { input_tokens: 0, output_tokens: 0 },
598
+ };
599
+ },
600
+ };
601
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
602
+
603
+ await modelWithBlocked.doGenerate({
604
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
605
+ } as any);
606
+
607
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any;
608
+ expect(call?.options?.model).not.toBe('override-model');
609
+ expect(call?.options?.abortController).not.toBe(externalAbortController);
610
+ });
611
+
612
+ it('should merge base env with settings and sdkOptions env', async () => {
613
+ const originalProcessEnv = { ...process.env };
614
+ try {
615
+ process.env.C2_ENV_PROCESS = 'from-process';
616
+ process.env.C2_ENV_OVERRIDE = 'process';
617
+ process.env.CLAUDE_CONFIG_DIR = 'from-process';
618
+
619
+ const modelWithEnv = new ClaudeCodeLanguageModel({
620
+ id: 'sonnet',
621
+ settings: {
622
+ env: {
623
+ C2_ENV_SETTINGS: 'from-settings',
624
+ C2_ENV_OVERRIDE: 'settings',
625
+ },
626
+ sdkOptions: {
627
+ env: {
628
+ C2_ENV_SDK: 'from-sdk',
629
+ C2_ENV_OVERRIDE: 'sdk',
630
+ },
631
+ },
632
+ } as any,
633
+ });
634
+
635
+ const mockResponse = {
636
+ async *[Symbol.asyncIterator]() {
637
+ yield {
638
+ type: 'result',
639
+ subtype: 'success',
640
+ session_id: 's-env-merge',
641
+ usage: { input_tokens: 0, output_tokens: 0 },
642
+ };
643
+ },
644
+ };
645
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
646
+
647
+ await modelWithEnv.doGenerate({
648
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
649
+ } as any);
650
+
651
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as any;
652
+ expect(call?.options?.env?.CLAUDE_CONFIG_DIR).toBe('from-process');
653
+ expect(call?.options?.env?.C2_ENV_SETTINGS).toBe('from-settings');
654
+ expect(call?.options?.env?.C2_ENV_SDK).toBe('from-sdk');
655
+ expect(call?.options?.env?.C2_ENV_OVERRIDE).toBe('sdk');
656
+ expect(call?.options?.env?.C2_ENV_PROCESS).toBeUndefined();
657
+ } finally {
658
+ process.env = originalProcessEnv;
659
+ }
660
+ });
661
+
662
+ it('should preserve stderr collector when sdkOptions.stderr is set', async () => {
663
+ const sdkStderr = vi.fn();
664
+ const modelWithStderr = new ClaudeCodeLanguageModel({
665
+ id: 'sonnet',
666
+ settings: {
667
+ sdkOptions: {
668
+ stderr: sdkStderr,
669
+ },
670
+ } as any,
671
+ });
672
+
673
+ vi.mocked(mockQuery).mockImplementation(({ options }: any) => {
674
+ if (options?.stderr) {
675
+ options.stderr('Error: Not authenticated\n');
676
+ options.stderr('Please run: claude login\n');
677
+ }
678
+ const error = new Error('Failed with exit code: 1');
679
+ (error as any).exitCode = 1;
680
+ throw error;
681
+ });
682
+
683
+ let thrownError: unknown;
684
+ try {
685
+ await modelWithStderr.doGenerate({
686
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
687
+ } as any);
688
+ } catch (error) {
689
+ thrownError = error;
690
+ }
691
+
692
+ expect(sdkStderr).toHaveBeenCalledWith('Error: Not authenticated\n');
693
+ expect(sdkStderr).toHaveBeenCalledWith('Please run: claude login\n');
694
+ const metadata = getErrorMetadata(thrownError);
695
+ expect(metadata?.stderr).toBe('Error: Not authenticated\nPlease run: claude login\n');
696
+ expect(metadata?.exitCode).toBe(1);
697
+ });
698
+ it('should generate text from SDK response', async () => {
699
+ const mockResponse = {
700
+ async *[Symbol.asyncIterator]() {
701
+ yield {
702
+ type: 'system',
703
+ subtype: 'init',
704
+ session_id: 'test-session-123',
705
+ };
706
+ yield {
707
+ type: 'assistant',
708
+ message: {
709
+ content: [
710
+ { type: 'text', text: 'Hello, ' },
711
+ { type: 'text', text: 'world!' },
712
+ ],
713
+ },
714
+ };
715
+ yield {
716
+ type: 'result',
717
+ subtype: 'success',
718
+ session_id: 'test-session-123',
719
+ usage: {
720
+ input_tokens: 10,
721
+ output_tokens: 5,
722
+ cache_creation_input_tokens: 0,
723
+ cache_read_input_tokens: 0,
724
+ },
725
+ total_cost_usd: 0.001,
726
+ duration_ms: 1000,
727
+ };
728
+ },
729
+ };
730
+
731
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
732
+
733
+ const result = await model.doGenerate({
734
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hello' }] }],
735
+ });
736
+
737
+ expect(result.content).toEqual([{ type: 'text', text: 'Hello, world!' }]);
738
+ expect(result.usage.inputTokens.total).toBe(10);
739
+ expect(result.usage.outputTokens.total).toBe(5);
740
+ expect(result.finishReason.unified).toBe('stop');
741
+ });
742
+
743
+ it('should handle error_max_turns as length finish reason', async () => {
744
+ const mockResponse = {
745
+ async *[Symbol.asyncIterator]() {
746
+ yield {
747
+ type: 'assistant',
748
+ message: {
749
+ content: [{ type: 'text', text: 'Partial response' }],
750
+ },
751
+ };
752
+ yield {
753
+ type: 'result',
754
+ subtype: 'error_max_turns',
755
+ session_id: 'test-session-123',
756
+ usage: {
757
+ input_tokens: 100,
758
+ output_tokens: 50,
759
+ },
760
+ };
761
+ },
762
+ };
763
+
764
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
765
+
766
+ const result = await model.doGenerate({
767
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Complex task' }] }],
768
+ });
769
+
770
+ expect(result.finishReason.unified).toBe('length');
771
+ });
772
+
773
+ it('should handle AbortError correctly', async () => {
774
+ const abortController = new AbortController();
775
+ const abortReason = new Error('User cancelled');
776
+
777
+ // Set up the mock to throw AbortError when called
778
+ vi.mocked(mockQuery).mockImplementation(() => {
779
+ throw new MockAbortError('Operation aborted');
780
+ });
781
+
782
+ // Abort before calling to ensure signal.aborted is true
783
+ abortController.abort(abortReason);
784
+
785
+ const promise = model.doGenerate({
786
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test abort' }] }],
787
+ abortSignal: abortController.signal,
788
+ });
789
+
790
+ // Should throw the abort reason since signal is aborted
791
+ await expect(promise).rejects.toThrow(abortReason);
792
+ });
793
+
794
+ it('should capture stderr from callback when SDK throws error', async () => {
795
+ const stderrMessages: string[] = [];
796
+ const stderrCallback = vi.fn((data: string) => {
797
+ stderrMessages.push(data);
798
+ });
799
+
800
+ const modelWithStderr = new ClaudeCodeLanguageModel({
801
+ id: 'sonnet',
802
+ settings: { stderr: stderrCallback },
803
+ });
804
+
805
+ // Mock query to call stderr callback then throw an error
806
+ vi.mocked(mockQuery).mockImplementation(({ options }: any) => {
807
+ // Simulate stderr output before error (e.g., auth failure message)
808
+ if (options?.stderr) {
809
+ options.stderr('Error: Not authenticated\n');
810
+ options.stderr('Please run: claude login\n');
811
+ }
812
+
813
+ // Throw an error with exitCode (like auth failure)
814
+ const error = new Error('Failed with exit code: 1');
815
+ (error as any).exitCode = 1;
816
+ throw error;
817
+ });
818
+
819
+ let thrownError: unknown;
820
+ try {
821
+ await modelWithStderr.doGenerate({
822
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
823
+ });
824
+ } catch (e) {
825
+ thrownError = e;
826
+ }
827
+
828
+ // Verify user's stderr callback was still called
829
+ expect(stderrCallback).toHaveBeenCalledWith('Error: Not authenticated\n');
830
+ expect(stderrCallback).toHaveBeenCalledWith('Please run: claude login\n');
831
+
832
+ // Verify the error contains the stderr data
833
+ expect(thrownError).toBeDefined();
834
+ const metadata = getErrorMetadata(thrownError);
835
+ expect(metadata).toBeDefined();
836
+ expect(metadata?.stderr).toBe('Error: Not authenticated\nPlease run: claude login\n');
837
+ expect(metadata?.exitCode).toBe(1);
838
+ });
839
+
840
+ it('should detect /login pattern as authentication error', async () => {
841
+ vi.mocked(mockQuery).mockImplementation(() => {
842
+ throw new Error('Please run /login to authenticate');
843
+ });
844
+
845
+ let thrownError: unknown;
846
+ try {
847
+ await model.doGenerate({
848
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
849
+ });
850
+ } catch (e) {
851
+ thrownError = e;
852
+ }
853
+
854
+ expect(thrownError).toBeDefined();
855
+ expect(isAuthenticationError(thrownError)).toBe(true);
856
+ });
857
+
858
+ it('should detect invalid api key pattern as authentication error', async () => {
859
+ vi.mocked(mockQuery).mockImplementation(() => {
860
+ throw new Error('Invalid API key provided');
861
+ });
862
+
863
+ let thrownError: unknown;
864
+ try {
865
+ await model.doGenerate({
866
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
867
+ });
868
+ } catch (e) {
869
+ thrownError = e;
870
+ }
871
+
872
+ expect(thrownError).toBeDefined();
873
+ expect(isAuthenticationError(thrownError)).toBe(true);
874
+ });
875
+
876
+ it('should throw error when result message has is_error flag', async () => {
877
+ // This simulates the actual CLI response when unauthenticated
878
+ const mockResponse = {
879
+ async *[Symbol.asyncIterator]() {
880
+ yield {
881
+ type: 'result',
882
+ subtype: 'success', // CLI returns success subtype even on error
883
+ is_error: true,
884
+ result: 'Invalid API key · Please run /login',
885
+ session_id: 'test-session',
886
+ total_cost_usd: 0,
887
+ usage: { input_tokens: 0, output_tokens: 0 },
888
+ };
889
+ },
890
+ };
891
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
892
+
893
+ let thrownError: unknown;
894
+ try {
895
+ await model.doGenerate({
896
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
897
+ });
898
+ } catch (e) {
899
+ thrownError = e;
900
+ }
901
+
902
+ expect(thrownError).toBeDefined();
903
+ expect(thrownError).toBeInstanceOf(Error);
904
+ // The error message should contain the original error content
905
+ expect((thrownError as Error).message).toContain('Invalid API key');
906
+ // The error should be converted to an auth error (contains /login pattern)
907
+ expect(isAuthenticationError(thrownError)).toBe(true);
908
+ });
909
+
910
+ it('should use default message when is_error is true but result field is missing', async () => {
911
+ const mockResponse = {
912
+ async *[Symbol.asyncIterator]() {
913
+ yield {
914
+ type: 'result',
915
+ subtype: 'success',
916
+ is_error: true,
917
+ // No result field
918
+ session_id: 'test-session',
919
+ };
920
+ },
921
+ };
922
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
923
+
924
+ let thrownError: unknown;
925
+ try {
926
+ await model.doGenerate({
927
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
928
+ });
929
+ } catch (e) {
930
+ thrownError = e;
931
+ }
932
+
933
+ expect(thrownError).toBeDefined();
934
+ expect(thrownError).toBeInstanceOf(Error);
935
+ expect((thrownError as Error).message).toBe('Claude Code CLI returned an error');
936
+ });
937
+
938
+ it('should include stderr in error metadata when is_error is true', async () => {
939
+ const stderrCallback = vi.fn();
940
+ const modelWithStderr = new ClaudeCodeLanguageModel({
941
+ id: 'sonnet',
942
+ settings: { stderr: stderrCallback },
943
+ });
944
+
945
+ vi.mocked(mockQuery).mockImplementation(({ options }: any) => {
946
+ // Simulate stderr being emitted before the is_error result
947
+ if (options?.stderr) {
948
+ options.stderr('Warning: some diagnostic info\n');
949
+ }
950
+ return {
951
+ async *[Symbol.asyncIterator]() {
952
+ yield {
953
+ type: 'result',
954
+ subtype: 'success',
955
+ is_error: true,
956
+ result: 'Some error occurred',
957
+ session_id: 'test-session',
958
+ };
959
+ },
960
+ } as any;
961
+ });
962
+
963
+ let thrownError: unknown;
964
+ try {
965
+ await modelWithStderr.doGenerate({
966
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
967
+ });
968
+ } catch (e) {
969
+ thrownError = e;
970
+ }
971
+
972
+ // Verify stderr callback was invoked
973
+ expect(stderrCallback).toHaveBeenCalledWith('Warning: some diagnostic info\n');
974
+
975
+ // Verify error was thrown
976
+ expect(thrownError).toBeDefined();
977
+ expect((thrownError as Error).message).toBe('Some error occurred');
978
+
979
+ // Verify error metadata includes collected stderr
980
+ const metadata = getErrorMetadata(thrownError);
981
+ expect(metadata?.stderr).toBe('Warning: some diagnostic info\n');
982
+ });
983
+
984
+ it('recovers from CLI truncation errors and returns buffered text', async () => {
985
+ const repeatedTasks = Array.from({ length: 400 }, (_, i) => `task-${i}`).join('","');
986
+ const partialResponse = `{"tasks": ["${repeatedTasks}`;
987
+ const truncationPosition = partialResponse.length;
988
+ const truncationError = new SyntaxError(
989
+ `Unexpected end of JSON input at position ${truncationPosition} (line 1 column ${
990
+ truncationPosition + 1
991
+ })`
992
+ );
993
+
994
+ const mockResponse = {
995
+ async *[Symbol.asyncIterator]() {
996
+ yield {
997
+ type: 'assistant',
998
+ message: {
999
+ content: [{ type: 'text', text: partialResponse }],
1000
+ },
1001
+ };
1002
+ throw truncationError;
1003
+ },
1004
+ };
1005
+
1006
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1007
+
1008
+ const result = await model.doGenerate({
1009
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Generate tasks' }] }],
1010
+ responseFormat: { type: 'json' },
1011
+ } as any);
1012
+
1013
+ expect(result.finishReason.unified).toBe('length');
1014
+ const hasTruncationWarning = result.warnings.some(
1015
+ (warning) =>
1016
+ 'message' in warning &&
1017
+ typeof warning.message === 'string' &&
1018
+ warning.message.includes('output ended unexpectedly')
1019
+ );
1020
+ expect(hasTruncationWarning).toBe(true);
1021
+ expect(result.providerMetadata?.['claude-code']?.truncated).toBe(true);
1022
+ expect(result.content[0]).toEqual(
1023
+ expect.objectContaining({
1024
+ type: 'text',
1025
+ text: expect.stringContaining('task-10'),
1026
+ })
1027
+ );
1028
+ });
1029
+
1030
+ it('propagates JSON syntax errors without marking truncation', async () => {
1031
+ const partialResponse = '{"tasks": ["task-1"}';
1032
+ const parseError = new SyntaxError('Unexpected token } in JSON at position 18');
1033
+
1034
+ const mockResponse = {
1035
+ async *[Symbol.asyncIterator]() {
1036
+ yield {
1037
+ type: 'assistant',
1038
+ message: {
1039
+ content: [{ type: 'text', text: partialResponse }],
1040
+ },
1041
+ };
1042
+ throw parseError;
1043
+ },
1044
+ };
1045
+
1046
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1047
+
1048
+ await expect(
1049
+ model.doGenerate({
1050
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Generate tasks' }] }],
1051
+ responseFormat: { type: 'json' },
1052
+ } as any)
1053
+ ).rejects.toThrow(/Unexpected token \}/);
1054
+ });
1055
+
1056
+ it('propagates short unexpected end errors without treating as truncation', async () => {
1057
+ const partialResponse = '{"tasks": "';
1058
+ const parseError = new SyntaxError('Unexpected end of JSON input');
1059
+
1060
+ const mockResponse = {
1061
+ async *[Symbol.asyncIterator]() {
1062
+ yield {
1063
+ type: 'assistant',
1064
+ message: {
1065
+ content: [{ type: 'text', text: partialResponse }],
1066
+ },
1067
+ };
1068
+ throw parseError;
1069
+ },
1070
+ };
1071
+
1072
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1073
+
1074
+ await expect(
1075
+ model.doGenerate({
1076
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Generate tasks' }] }],
1077
+ responseFormat: { type: 'json' },
1078
+ } as any)
1079
+ ).rejects.toThrow(/Unexpected end of JSON input/);
1080
+ });
1081
+
1082
+ it('should include modelUsage in providerMetadata when available', async () => {
1083
+ const mockModelUsage = {
1084
+ 'claude-sonnet-4-20250514': {
1085
+ inputTokens: 100,
1086
+ outputTokens: 50,
1087
+ cacheReadInputTokens: 20,
1088
+ cacheCreationInputTokens: 10,
1089
+ webSearchRequests: 0,
1090
+ costUSD: 0.001,
1091
+ contextWindow: 200000,
1092
+ maxOutputTokens: 16384,
1093
+ },
1094
+ };
1095
+
1096
+ const mockResponse = {
1097
+ async *[Symbol.asyncIterator]() {
1098
+ yield {
1099
+ type: 'result',
1100
+ subtype: 'success',
1101
+ session_id: 'model-usage-session',
1102
+ usage: { input_tokens: 100, output_tokens: 50 },
1103
+ total_cost_usd: 0.001,
1104
+ duration_ms: 500,
1105
+ modelUsage: mockModelUsage,
1106
+ };
1107
+ },
1108
+ };
1109
+
1110
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1111
+
1112
+ const result = await model.doGenerate({
1113
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
1114
+ });
1115
+
1116
+ expect(result.providerMetadata?.['claude-code']?.modelUsage).toEqual(mockModelUsage);
1117
+ });
1118
+
1119
+ it('should not include modelUsage in providerMetadata when not available', async () => {
1120
+ const mockResponse = {
1121
+ async *[Symbol.asyncIterator]() {
1122
+ yield {
1123
+ type: 'result',
1124
+ subtype: 'success',
1125
+ session_id: 'no-model-usage-session',
1126
+ usage: { input_tokens: 100, output_tokens: 50 },
1127
+ total_cost_usd: 0.001,
1128
+ duration_ms: 500,
1129
+ // No modelUsage field
1130
+ };
1131
+ },
1132
+ };
1133
+
1134
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1135
+
1136
+ const result = await model.doGenerate({
1137
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
1138
+ });
1139
+
1140
+ expect(result.providerMetadata?.['claude-code']?.modelUsage).toBeUndefined();
1141
+ });
1142
+ });
1143
+
1144
+ describe('doStream', () => {
1145
+ it('invokes onQueryCreated with the query response', async () => {
1146
+ const onQueryCreated = vi.fn();
1147
+ const modelWithHook = new ClaudeCodeLanguageModel({
1148
+ id: 'sonnet',
1149
+ settings: { onQueryCreated } as any,
1150
+ });
1151
+
1152
+ const mockResponse = {
1153
+ async *[Symbol.asyncIterator]() {
1154
+ yield {
1155
+ type: 'assistant',
1156
+ message: { content: [{ type: 'text', text: 'Hello' }] },
1157
+ };
1158
+ yield {
1159
+ type: 'result',
1160
+ subtype: 'success',
1161
+ session_id: 'stream-query',
1162
+ usage: { input_tokens: 1, output_tokens: 1 },
1163
+ total_cost_usd: 0.001,
1164
+ duration_ms: 50,
1165
+ };
1166
+ },
1167
+ };
1168
+
1169
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1170
+
1171
+ const { stream } = await modelWithHook.doStream({
1172
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hello' }] }],
1173
+ });
1174
+
1175
+ const reader = stream.getReader();
1176
+ while (true) {
1177
+ const { done } = await reader.read();
1178
+ if (done) break;
1179
+ }
1180
+
1181
+ expect(onQueryCreated).toHaveBeenCalledTimes(1);
1182
+ expect(onQueryCreated).toHaveBeenCalledWith(mockResponse);
1183
+ });
1184
+
1185
+ it('should stream text chunks from SDK response', async () => {
1186
+ const mockResponse = {
1187
+ async *[Symbol.asyncIterator]() {
1188
+ yield {
1189
+ type: 'assistant',
1190
+ message: {
1191
+ content: [{ type: 'text', text: 'Hello' }],
1192
+ },
1193
+ };
1194
+ yield {
1195
+ type: 'assistant',
1196
+ message: {
1197
+ content: [{ type: 'text', text: ', world!' }],
1198
+ },
1199
+ };
1200
+ yield {
1201
+ type: 'result',
1202
+ subtype: 'success',
1203
+ session_id: 'test-session-123',
1204
+ usage: {
1205
+ input_tokens: 10,
1206
+ output_tokens: 5,
1207
+ },
1208
+ total_cost_usd: 0.001,
1209
+ duration_ms: 1000,
1210
+ };
1211
+ },
1212
+ };
1213
+
1214
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1215
+
1216
+ const result = await model.doStream({
1217
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hello' }] }],
1218
+ });
1219
+
1220
+ const chunks: any[] = [];
1221
+ const reader = result.stream.getReader();
1222
+
1223
+ while (true) {
1224
+ const { done, value } = await reader.read();
1225
+ if (done) break;
1226
+ chunks.push(value);
1227
+ }
1228
+
1229
+ expect(chunks).toHaveLength(6);
1230
+ expect(chunks[0]).toMatchObject({
1231
+ type: 'stream-start',
1232
+ warnings: [],
1233
+ });
1234
+ expect(chunks[1]).toMatchObject({
1235
+ type: 'text-start',
1236
+ });
1237
+ expect(chunks[2]).toMatchObject({
1238
+ type: 'text-delta',
1239
+ delta: 'Hello',
1240
+ });
1241
+ expect(chunks[3]).toMatchObject({
1242
+ type: 'text-delta',
1243
+ delta: ', world!',
1244
+ });
1245
+ expect(chunks[4]).toMatchObject({
1246
+ type: 'text-end',
1247
+ });
1248
+ expect(chunks[5]).toMatchObject({
1249
+ type: 'finish',
1250
+ finishReason: { unified: 'stop' },
1251
+ usage: {
1252
+ inputTokens: { total: 10 },
1253
+ outputTokens: { total: 5 },
1254
+ },
1255
+ });
1256
+ });
1257
+
1258
+ it('should emit error chunk when result message has is_error flag in streaming', async () => {
1259
+ // This simulates the actual CLI response when unauthenticated during streaming
1260
+ const mockResponse = {
1261
+ async *[Symbol.asyncIterator]() {
1262
+ yield {
1263
+ type: 'result',
1264
+ subtype: 'success', // CLI returns success subtype even on error
1265
+ is_error: true,
1266
+ result: 'Invalid API key · Please run /login',
1267
+ session_id: 'test-session',
1268
+ total_cost_usd: 0,
1269
+ usage: { input_tokens: 0, output_tokens: 0 },
1270
+ };
1271
+ },
1272
+ };
1273
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1274
+
1275
+ const result = await model.doStream({
1276
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
1277
+ });
1278
+
1279
+ const chunks: ExtendedStreamPart[] = [];
1280
+ const reader = result.stream.getReader();
1281
+
1282
+ while (true) {
1283
+ const { done, value } = await reader.read();
1284
+ if (done) break;
1285
+ chunks.push(value);
1286
+ }
1287
+
1288
+ // Should emit stream-start and then error
1289
+ expect(chunks).toHaveLength(2);
1290
+ expect(chunks[0]).toMatchObject({
1291
+ type: 'stream-start',
1292
+ });
1293
+ expect(chunks[1]).toMatchObject({
1294
+ type: 'error',
1295
+ });
1296
+ // The error should contain the auth message
1297
+ expect((chunks[1] as any).error.message).toContain('Invalid API key');
1298
+ // The error should be converted to an auth error (contains /login pattern)
1299
+ expect(isAuthenticationError((chunks[1] as any).error)).toBe(true);
1300
+ });
1301
+
1302
+ it('should use stop_reason from result message for stream finish reason', async () => {
1303
+ const mockResponse = {
1304
+ async *[Symbol.asyncIterator]() {
1305
+ yield {
1306
+ type: 'assistant',
1307
+ message: {
1308
+ type: 'message',
1309
+ role: 'assistant',
1310
+ content: [{ type: 'text', text: 'Hello' }],
1311
+ },
1312
+ };
1313
+ yield {
1314
+ type: 'result',
1315
+ subtype: 'success',
1316
+ session_id: 's-stream-stop',
1317
+ usage: { input_tokens: 10, output_tokens: 5 },
1318
+ total_cost_usd: 0.001,
1319
+ duration_ms: 500,
1320
+ stop_reason: 'max_tokens',
1321
+ };
1322
+ },
1323
+ };
1324
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1325
+
1326
+ const result = await model.doStream({
1327
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
1328
+ });
1329
+
1330
+ const chunks: ExtendedStreamPart[] = [];
1331
+ const reader = result.stream.getReader();
1332
+ while (true) {
1333
+ const { done, value } = await reader.read();
1334
+ if (done) break;
1335
+ chunks.push(value);
1336
+ }
1337
+
1338
+ const finishEvent = chunks.find((c) => c.type === 'finish');
1339
+ expect(finishEvent).toBeDefined();
1340
+ expect((finishEvent as any).finishReason).toEqual({
1341
+ unified: 'length',
1342
+ raw: 'max_tokens',
1343
+ });
1344
+ });
1345
+
1346
+ describe('stream_event handling (includePartialMessages)', () => {
1347
+ // Helper to create stream_event messages with text_delta
1348
+ const createTextDeltaEvent = (text: string, index = 0) => ({
1349
+ type: 'stream_event',
1350
+ event: {
1351
+ type: 'content_block_delta',
1352
+ index,
1353
+ delta: { type: 'text_delta', text },
1354
+ },
1355
+ });
1356
+
1357
+ // Helper to create a result message
1358
+ const createResultMessage = (sessionId = 'test-session') => ({
1359
+ type: 'result',
1360
+ subtype: 'success',
1361
+ session_id: sessionId,
1362
+ usage: { input_tokens: 10, output_tokens: 5 },
1363
+ total_cost_usd: 0.001,
1364
+ duration_ms: 1000,
1365
+ });
1366
+
1367
+ it('streams text via stream_event deltas', async () => {
1368
+ const mockResponse = {
1369
+ async *[Symbol.asyncIterator]() {
1370
+ yield createTextDeltaEvent('Hello');
1371
+ yield createTextDeltaEvent(' world');
1372
+ yield createResultMessage();
1373
+ },
1374
+ };
1375
+
1376
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1377
+
1378
+ const result = await model.doStream({
1379
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hello' }] }],
1380
+ });
1381
+
1382
+ const chunks: any[] = [];
1383
+ const reader = result.stream.getReader();
1384
+ while (true) {
1385
+ const { done, value } = await reader.read();
1386
+ if (done) break;
1387
+ chunks.push(value);
1388
+ }
1389
+
1390
+ expect(chunks).toHaveLength(6);
1391
+ expect(chunks[0]).toMatchObject({ type: 'stream-start' });
1392
+ expect(chunks[1]).toMatchObject({ type: 'text-start' });
1393
+ expect(chunks[2]).toMatchObject({ type: 'text-delta', delta: 'Hello' });
1394
+ expect(chunks[3]).toMatchObject({ type: 'text-delta', delta: ' world' });
1395
+ expect(chunks[4]).toMatchObject({ type: 'text-end' });
1396
+ expect(chunks[5]).toMatchObject({ type: 'finish', finishReason: { unified: 'stop' } });
1397
+ });
1398
+
1399
+ it('deduplicates text when assistant messages follow stream_events', async () => {
1400
+ const mockResponse = {
1401
+ async *[Symbol.asyncIterator]() {
1402
+ // Stream events deliver text token-by-token
1403
+ yield createTextDeltaEvent('Hello');
1404
+ yield createTextDeltaEvent(' world');
1405
+ // Assistant message arrives with cumulative text (same content)
1406
+ yield {
1407
+ type: 'assistant',
1408
+ message: { content: [{ type: 'text', text: 'Hello world' }] },
1409
+ };
1410
+ yield createResultMessage();
1411
+ },
1412
+ };
1413
+
1414
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1415
+
1416
+ const result = await model.doStream({
1417
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hello' }] }],
1418
+ });
1419
+
1420
+ const chunks: any[] = [];
1421
+ const reader = result.stream.getReader();
1422
+ while (true) {
1423
+ const { done, value } = await reader.read();
1424
+ if (done) break;
1425
+ chunks.push(value);
1426
+ }
1427
+
1428
+ // Should NOT have duplicate text from assistant message
1429
+ const textDeltas = chunks.filter((c) => c.type === 'text-delta');
1430
+ expect(textDeltas).toHaveLength(2);
1431
+ expect(textDeltas[0].delta).toBe('Hello');
1432
+ expect(textDeltas[1].delta).toBe(' world');
1433
+ });
1434
+
1435
+ it('emits new text from assistant message that extends beyond streamed content', async () => {
1436
+ const mockResponse = {
1437
+ async *[Symbol.asyncIterator]() {
1438
+ // Stream event delivers partial text
1439
+ yield createTextDeltaEvent('Hello');
1440
+ // Assistant message has more text than was streamed
1441
+ yield {
1442
+ type: 'assistant',
1443
+ message: { content: [{ type: 'text', text: 'Hello world!' }] },
1444
+ };
1445
+ yield createResultMessage();
1446
+ },
1447
+ };
1448
+
1449
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1450
+
1451
+ const result = await model.doStream({
1452
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hello' }] }],
1453
+ });
1454
+
1455
+ const chunks: any[] = [];
1456
+ const reader = result.stream.getReader();
1457
+ while (true) {
1458
+ const { done, value } = await reader.read();
1459
+ if (done) break;
1460
+ chunks.push(value);
1461
+ }
1462
+
1463
+ // Should have 'Hello' from stream_event and ' world!' from assistant message
1464
+ const textDeltas = chunks.filter((c) => c.type === 'text-delta');
1465
+ expect(textDeltas).toHaveLength(2);
1466
+ expect(textDeltas[0].delta).toBe('Hello');
1467
+ expect(textDeltas[1].delta).toBe(' world!');
1468
+ });
1469
+
1470
+ it('accumulates text without streaming in JSON mode', async () => {
1471
+ const mockResponse = {
1472
+ async *[Symbol.asyncIterator]() {
1473
+ yield createTextDeltaEvent('{"key":');
1474
+ yield createTextDeltaEvent('"value"}');
1475
+ yield createResultMessage();
1476
+ },
1477
+ };
1478
+
1479
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1480
+
1481
+ const result = await model.doStream({
1482
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Return JSON' }] }],
1483
+ responseFormat: { type: 'json' },
1484
+ });
1485
+
1486
+ const chunks: any[] = [];
1487
+ const reader = result.stream.getReader();
1488
+ while (true) {
1489
+ const { done, value } = await reader.read();
1490
+ if (done) break;
1491
+ chunks.push(value);
1492
+ }
1493
+
1494
+ // In JSON mode, text should be accumulated and emitted at the end
1495
+ const textDeltas = chunks.filter((c) => c.type === 'text-delta');
1496
+ expect(textDeltas).toHaveLength(1);
1497
+ expect(textDeltas[0].delta).toBe('{"key":"value"}');
1498
+ });
1499
+
1500
+ it('falls back to assistant message streaming when no stream_events received', async () => {
1501
+ // This tests the original behavior when includePartialMessages doesn't produce stream_events
1502
+ const mockResponse = {
1503
+ async *[Symbol.asyncIterator]() {
1504
+ yield {
1505
+ type: 'assistant',
1506
+ message: { content: [{ type: 'text', text: 'Hello' }] },
1507
+ };
1508
+ yield {
1509
+ type: 'assistant',
1510
+ message: { content: [{ type: 'text', text: ', world!' }] },
1511
+ };
1512
+ yield createResultMessage();
1513
+ },
1514
+ };
1515
+
1516
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1517
+
1518
+ const result = await model.doStream({
1519
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hello' }] }],
1520
+ });
1521
+
1522
+ const chunks: any[] = [];
1523
+ const reader = result.stream.getReader();
1524
+ while (true) {
1525
+ const { done, value } = await reader.read();
1526
+ if (done) break;
1527
+ chunks.push(value);
1528
+ }
1529
+
1530
+ const textDeltas = chunks.filter((c) => c.type === 'text-delta');
1531
+ expect(textDeltas).toHaveLength(2);
1532
+ expect(textDeltas[0].delta).toBe('Hello');
1533
+ expect(textDeltas[1].delta).toBe(', world!');
1534
+ });
1535
+
1536
+ it('ignores non-text_delta stream_events', async () => {
1537
+ const mockResponse = {
1538
+ async *[Symbol.asyncIterator]() {
1539
+ // content_block_start should be ignored
1540
+ yield {
1541
+ type: 'stream_event',
1542
+ event: {
1543
+ type: 'content_block_start',
1544
+ index: 0,
1545
+ content_block: { type: 'text', text: '' },
1546
+ },
1547
+ };
1548
+ // text_delta should be processed
1549
+ yield createTextDeltaEvent('Hi');
1550
+ // content_block_stop should be ignored
1551
+ yield {
1552
+ type: 'stream_event',
1553
+ event: { type: 'content_block_stop', index: 0 },
1554
+ };
1555
+ // message_delta should be ignored
1556
+ yield {
1557
+ type: 'stream_event',
1558
+ event: { type: 'message_delta', delta: { stop_reason: 'end_turn' } },
1559
+ };
1560
+ yield createResultMessage();
1561
+ },
1562
+ };
1563
+
1564
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1565
+
1566
+ const result = await model.doStream({
1567
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hi' }] }],
1568
+ });
1569
+
1570
+ const chunks: any[] = [];
1571
+ const reader = result.stream.getReader();
1572
+ while (true) {
1573
+ const { done, value } = await reader.read();
1574
+ if (done) break;
1575
+ chunks.push(value);
1576
+ }
1577
+
1578
+ // Only one text-delta from the text_delta event
1579
+ const textDeltas = chunks.filter((c) => c.type === 'text-delta');
1580
+ expect(textDeltas).toHaveLength(1);
1581
+ expect(textDeltas[0].delta).toBe('Hi');
1582
+ });
1583
+
1584
+ it('preserves tool input when tool block stop follows assistant tool_use', async () => {
1585
+ const toolUseId = 'toolu_race';
1586
+ const toolName = 'RaceTool';
1587
+ const toolInput = { plan: 'do stuff', steps: ['a', 'b'] };
1588
+
1589
+ const mockResponse = {
1590
+ async *[Symbol.asyncIterator]() {
1591
+ yield {
1592
+ type: 'stream_event',
1593
+ event: {
1594
+ type: 'content_block_start',
1595
+ index: 0,
1596
+ content_block: { type: 'tool_use', id: toolUseId, name: toolName },
1597
+ },
1598
+ };
1599
+ yield {
1600
+ type: 'assistant',
1601
+ message: {
1602
+ content: [
1603
+ {
1604
+ type: 'tool_use',
1605
+ id: toolUseId,
1606
+ name: toolName,
1607
+ input: toolInput,
1608
+ },
1609
+ ],
1610
+ },
1611
+ };
1612
+ yield {
1613
+ type: 'stream_event',
1614
+ event: { type: 'content_block_stop', index: 0 },
1615
+ };
1616
+ yield createResultMessage('tool-race-session');
1617
+ },
1618
+ };
1619
+
1620
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1621
+
1622
+ const result = await model.doStream({
1623
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Run tool' }] }],
1624
+ });
1625
+
1626
+ const events: any[] = [];
1627
+ const reader = result.stream.getReader();
1628
+ while (true) {
1629
+ const { done, value } = await reader.read();
1630
+ if (done) break;
1631
+ events.push(value);
1632
+ }
1633
+
1634
+ const toolInputDelta = events.find((event) => event.type === 'tool-input-delta') as any;
1635
+ const toolCall = events.find((event) => event.type === 'tool-call') as any;
1636
+
1637
+ expect(toolInputDelta?.delta).toBe(JSON.stringify(toolInput));
1638
+ expect(toolCall?.input).toBe(JSON.stringify(toolInput));
1639
+ expect(toolCall?.providerMetadata?.['claude-code']?.rawInput).toBe(
1640
+ JSON.stringify(toolInput)
1641
+ );
1642
+ });
1643
+
1644
+ it('does not emit duplicate text-end when user message arrives mid-block', async () => {
1645
+ const mockResponse = {
1646
+ async *[Symbol.asyncIterator]() {
1647
+ yield {
1648
+ type: 'stream_event',
1649
+ event: {
1650
+ type: 'content_block_start',
1651
+ index: 0,
1652
+ content_block: { type: 'text', text: '' },
1653
+ },
1654
+ };
1655
+ yield createTextDeltaEvent('Hello', 0);
1656
+ yield {
1657
+ type: 'user',
1658
+ message: {
1659
+ content: [],
1660
+ },
1661
+ };
1662
+ yield {
1663
+ type: 'stream_event',
1664
+ event: { type: 'content_block_stop', index: 0 },
1665
+ };
1666
+ yield createResultMessage('mid-block-user');
1667
+ },
1668
+ };
1669
+
1670
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1671
+
1672
+ const result = await model.doStream({
1673
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hi' }] }],
1674
+ });
1675
+
1676
+ const chunks: any[] = [];
1677
+ const reader = result.stream.getReader();
1678
+ while (true) {
1679
+ const { done, value } = await reader.read();
1680
+ if (done) break;
1681
+ chunks.push(value);
1682
+ }
1683
+
1684
+ const textEnds = chunks.filter((c) => c.type === 'text-end');
1685
+ expect(textEnds).toHaveLength(1);
1686
+ });
1687
+
1688
+ it('recovers from truncation when streaming via stream_events', async () => {
1689
+ // Generate enough text to exceed MIN_TRUNCATION_LENGTH (512 chars)
1690
+ const longText = 'A'.repeat(600);
1691
+ const mockResponse = {
1692
+ async *[Symbol.asyncIterator]() {
1693
+ // Stream text via stream_events
1694
+ yield createTextDeltaEvent(longText);
1695
+ // Simulate truncation error before assistant message arrives
1696
+ throw new SyntaxError('Unexpected end of JSON input');
1697
+ },
1698
+ };
1699
+
1700
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1701
+
1702
+ const result = await model.doStream({
1703
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Generate text' }] }],
1704
+ });
1705
+
1706
+ const chunks: any[] = [];
1707
+ const reader = result.stream.getReader();
1708
+ while (true) {
1709
+ const { done, value } = await reader.read();
1710
+ if (done) break;
1711
+ chunks.push(value);
1712
+ }
1713
+
1714
+ // Should have recovered with truncated text
1715
+ const textDeltas = chunks.filter((c) => c.type === 'text-delta');
1716
+ expect(textDeltas.length).toBeGreaterThan(0);
1717
+ expect(textDeltas[0].delta).toBe(longText);
1718
+
1719
+ // Should have finish event with truncated metadata
1720
+ const finishEvent = chunks.find((c) => c.type === 'finish');
1721
+ expect(finishEvent).toBeDefined();
1722
+ expect(finishEvent.finishReason.unified).toBe('length'); // Truncation uses 'length' finish reason
1723
+ expect(finishEvent.providerMetadata?.['claude-code']?.truncated).toBe(true);
1724
+ });
1725
+
1726
+ // Helper to create stream_event messages with input_json_delta (structured output)
1727
+ const createJsonDeltaEvent = (partialJson: string, index = 0) => ({
1728
+ type: 'stream_event',
1729
+ event: {
1730
+ type: 'content_block_delta',
1731
+ index,
1732
+ delta: { type: 'input_json_delta', partial_json: partialJson },
1733
+ },
1734
+ });
1735
+
1736
+ it('streams JSON via input_json_delta events in JSON mode', async () => {
1737
+ const mockResponse = {
1738
+ async *[Symbol.asyncIterator]() {
1739
+ yield createJsonDeltaEvent('{"name":');
1740
+ yield createJsonDeltaEvent('"Alice"');
1741
+ yield createJsonDeltaEvent(',"age":');
1742
+ yield createJsonDeltaEvent('30}');
1743
+ yield {
1744
+ type: 'result',
1745
+ subtype: 'success',
1746
+ session_id: 'json-stream-session',
1747
+ structured_output: { name: 'Alice', age: 30 },
1748
+ usage: { input_tokens: 10, output_tokens: 5 },
1749
+ };
1750
+ },
1751
+ };
1752
+
1753
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1754
+
1755
+ const result = await model.doStream({
1756
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Generate JSON' }] }],
1757
+ responseFormat: { type: 'json', schema: { type: 'object' } },
1758
+ });
1759
+
1760
+ const chunks: any[] = [];
1761
+ const reader = result.stream.getReader();
1762
+ while (true) {
1763
+ const { done, value } = await reader.read();
1764
+ if (done) break;
1765
+ chunks.push(value);
1766
+ }
1767
+
1768
+ // Should have streamed JSON deltas (not accumulated into one chunk)
1769
+ const textDeltas = chunks.filter((c) => c.type === 'text-delta');
1770
+ expect(textDeltas.length).toBe(4);
1771
+ expect(textDeltas[0].delta).toBe('{"name":');
1772
+ expect(textDeltas[1].delta).toBe('"Alice"');
1773
+ expect(textDeltas[2].delta).toBe(',"age":');
1774
+ expect(textDeltas[3].delta).toBe('30}');
1775
+ });
1776
+
1777
+ it('ignores input_json_delta events in non-JSON mode', async () => {
1778
+ const mockResponse = {
1779
+ async *[Symbol.asyncIterator]() {
1780
+ // Text delta should be emitted
1781
+ yield createTextDeltaEvent('Hello');
1782
+ // JSON delta should be ignored in non-JSON mode (it is tool input)
1783
+ yield createJsonDeltaEvent('{"key":"value"}');
1784
+ yield createResultMessage();
1785
+ },
1786
+ };
1787
+
1788
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1789
+
1790
+ const result = await model.doStream({
1791
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hello' }] }],
1792
+ // No responseFormat - plain text mode
1793
+ });
1794
+
1795
+ const chunks: any[] = [];
1796
+ const reader = result.stream.getReader();
1797
+ while (true) {
1798
+ const { done, value } = await reader.read();
1799
+ if (done) break;
1800
+ chunks.push(value);
1801
+ }
1802
+
1803
+ // Should only have 'Hello' text delta, not the JSON delta
1804
+ const textDeltas = chunks.filter((c) => c.type === 'text-delta');
1805
+ expect(textDeltas).toHaveLength(1);
1806
+ expect(textDeltas[0].delta).toBe('Hello');
1807
+ });
1808
+
1809
+ it('skips empty input_json_delta events', async () => {
1810
+ const mockResponse = {
1811
+ async *[Symbol.asyncIterator]() {
1812
+ yield createJsonDeltaEvent(''); // Empty delta (common at start)
1813
+ yield createJsonDeltaEvent('{"key":');
1814
+ yield createJsonDeltaEvent(''); // Another empty
1815
+ yield createJsonDeltaEvent('"value"}');
1816
+ yield {
1817
+ type: 'result',
1818
+ subtype: 'success',
1819
+ session_id: 'json-empty-session',
1820
+ structured_output: { key: 'value' },
1821
+ usage: { input_tokens: 10, output_tokens: 5 },
1822
+ };
1823
+ },
1824
+ };
1825
+
1826
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1827
+
1828
+ const result = await model.doStream({
1829
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Generate JSON' }] }],
1830
+ responseFormat: { type: 'json', schema: { type: 'object' } },
1831
+ });
1832
+
1833
+ const chunks: any[] = [];
1834
+ const reader = result.stream.getReader();
1835
+ while (true) {
1836
+ const { done, value } = await reader.read();
1837
+ if (done) break;
1838
+ chunks.push(value);
1839
+ }
1840
+
1841
+ // Should only have non-empty JSON deltas
1842
+ const textDeltas = chunks.filter((c) => c.type === 'text-delta');
1843
+ expect(textDeltas).toHaveLength(2);
1844
+ expect(textDeltas[0].delta).toBe('{"key":');
1845
+ expect(textDeltas[1].delta).toBe('"value"}');
1846
+ });
1847
+
1848
+ it('does not double-emit JSON when structured_output arrives after streaming', async () => {
1849
+ const mockResponse = {
1850
+ async *[Symbol.asyncIterator]() {
1851
+ // JSON deltas stream the content
1852
+ yield createJsonDeltaEvent('{"name":"Bob"}');
1853
+ // Result arrives with structured_output (same content)
1854
+ yield {
1855
+ type: 'result',
1856
+ subtype: 'success',
1857
+ session_id: 'no-double-session',
1858
+ structured_output: { name: 'Bob' },
1859
+ usage: { input_tokens: 10, output_tokens: 5 },
1860
+ };
1861
+ },
1862
+ };
1863
+
1864
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1865
+
1866
+ const result = await model.doStream({
1867
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Generate JSON' }] }],
1868
+ responseFormat: { type: 'json', schema: { type: 'object' } },
1869
+ });
1870
+
1871
+ const chunks: any[] = [];
1872
+ const reader = result.stream.getReader();
1873
+ while (true) {
1874
+ const { done, value } = await reader.read();
1875
+ if (done) break;
1876
+ chunks.push(value);
1877
+ }
1878
+
1879
+ // Should NOT have duplicate JSON - only the streamed delta
1880
+ const textDeltas = chunks.filter((c) => c.type === 'text-delta');
1881
+ expect(textDeltas).toHaveLength(1);
1882
+ expect(textDeltas[0].delta).toBe('{"name":"Bob"}');
1883
+
1884
+ // Should have proper text lifecycle (start, delta, end)
1885
+ const textStarts = chunks.filter((c) => c.type === 'text-start');
1886
+ const textEnds = chunks.filter((c) => c.type === 'text-end');
1887
+ expect(textStarts).toHaveLength(1);
1888
+ expect(textEnds).toHaveLength(1);
1889
+ });
1890
+
1891
+ it('does not double-emit JSON when tool calls follow JSON streaming', async () => {
1892
+ const toolUseId = 'toolu_json_1';
1893
+ const toolName = 'noop';
1894
+ const toolInput = { ok: true };
1895
+ const mockResponse = {
1896
+ async *[Symbol.asyncIterator]() {
1897
+ // JSON deltas stream the content
1898
+ yield createJsonDeltaEvent('{"name":"Bob"}');
1899
+ // Assistant emits a tool call after JSON streaming
1900
+ yield {
1901
+ type: 'assistant',
1902
+ message: {
1903
+ content: [
1904
+ {
1905
+ type: 'tool_use',
1906
+ id: toolUseId,
1907
+ name: toolName,
1908
+ input: toolInput,
1909
+ },
1910
+ ],
1911
+ },
1912
+ };
1913
+ // Result arrives with structured_output (same content)
1914
+ yield {
1915
+ type: 'result',
1916
+ subtype: 'success',
1917
+ session_id: 'json-tool-session',
1918
+ structured_output: { name: 'Bob' },
1919
+ usage: { input_tokens: 10, output_tokens: 5 },
1920
+ };
1921
+ },
1922
+ };
1923
+
1924
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1925
+
1926
+ const result = await model.doStream({
1927
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Generate JSON' }] }],
1928
+ responseFormat: { type: 'json', schema: { type: 'object' } },
1929
+ });
1930
+
1931
+ const chunks: any[] = [];
1932
+ const reader = result.stream.getReader();
1933
+ while (true) {
1934
+ const { done, value } = await reader.read();
1935
+ if (done) break;
1936
+ chunks.push(value);
1937
+ }
1938
+
1939
+ // Should NOT have duplicate JSON - only the streamed delta
1940
+ const textStarts = chunks.filter((c) => c.type === 'text-start');
1941
+ const textDeltas = chunks.filter((c) => c.type === 'text-delta');
1942
+ const textEnds = chunks.filter((c) => c.type === 'text-end');
1943
+ expect(textStarts).toHaveLength(1);
1944
+ expect(textDeltas).toHaveLength(1);
1945
+ expect(textDeltas[0].delta).toBe('{"name":"Bob"}');
1946
+ expect(textEnds).toHaveLength(1);
1947
+ });
1948
+ });
1949
+
1950
+ it('emits streaming prerequisite warning when images are provided without streaming input', async () => {
1951
+ const modelWithStreamingOff = new ClaudeCodeLanguageModel({
1952
+ id: 'sonnet',
1953
+ settings: { streamingInput: 'off' } as any,
1954
+ });
1955
+
1956
+ const mockResponse = {
1957
+ async *[Symbol.asyncIterator]() {
1958
+ yield {
1959
+ type: 'result',
1960
+ subtype: 'success',
1961
+ session_id: 'warn-session',
1962
+ usage: { input_tokens: 0, output_tokens: 0 },
1963
+ };
1964
+ },
1965
+ };
1966
+
1967
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
1968
+
1969
+ const result = await modelWithStreamingOff.doStream({
1970
+ prompt: [
1971
+ {
1972
+ role: 'user',
1973
+ content: [
1974
+ { type: 'text', text: 'Look at this image.' },
1975
+ { type: 'image', image: 'data:image/png;base64,aGVsbG8=' },
1976
+ ],
1977
+ },
1978
+ ],
1979
+ } as any);
1980
+
1981
+ const reader = result.stream.getReader();
1982
+ const start = await reader.read();
1983
+ expect(start.done).toBe(false);
1984
+ expect(start.value).toMatchObject({
1985
+ type: 'stream-start',
1986
+ warnings: expect.arrayContaining([
1987
+ expect.objectContaining({
1988
+ type: 'other',
1989
+ message: STREAMING_WARNING_MESSAGE,
1990
+ }),
1991
+ ]),
1992
+ });
1993
+
1994
+ await reader.cancel();
1995
+
1996
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0];
1997
+ expect(typeof call.prompt).toBe('string');
1998
+ });
1999
+
2000
+ it('should emit JSON from structured_output in object-json mode and return finish metadata', async () => {
2001
+ // SDK 0.1.45+ returns structured_output directly in the result message
2002
+ const mockResponse = {
2003
+ async *[Symbol.asyncIterator]() {
2004
+ yield {
2005
+ type: 'result',
2006
+ subtype: 'success',
2007
+ session_id: 'json-session-1',
2008
+ structured_output: { a: 1, b: 2 },
2009
+ usage: {
2010
+ input_tokens: 6,
2011
+ output_tokens: 3,
2012
+ },
2013
+ total_cost_usd: 0.001,
2014
+ duration_ms: 1000,
2015
+ };
2016
+ },
2017
+ };
2018
+
2019
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
2020
+
2021
+ const result = await model.doStream({
2022
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Return JSON' }] }],
2023
+ temperature: 0.5, // This will trigger a warning
2024
+ responseFormat: { type: 'json', schema: { type: 'object' } }, // Add responseFormat with schema
2025
+ });
2026
+
2027
+ const chunks: any[] = [];
2028
+ const reader = result.stream.getReader();
2029
+
2030
+ while (true) {
2031
+ const { done, value } = await reader.read();
2032
+ if (done) break;
2033
+ chunks.push(value);
2034
+ }
2035
+
2036
+ expect(chunks).toHaveLength(5);
2037
+ expect(chunks[0]).toMatchObject({
2038
+ type: 'stream-start',
2039
+ warnings: expect.arrayContaining([
2040
+ expect.objectContaining({
2041
+ type: 'unsupported',
2042
+ feature: 'temperature',
2043
+ }),
2044
+ ]),
2045
+ });
2046
+ expect(chunks[1]).toMatchObject({
2047
+ type: 'text-start',
2048
+ });
2049
+ expect(chunks[2]).toMatchObject({
2050
+ type: 'text-delta',
2051
+ delta: '{"a":1,"b":2}',
2052
+ });
2053
+ expect(chunks[3]).toMatchObject({
2054
+ type: 'text-end',
2055
+ });
2056
+ expect(chunks[4]).toMatchObject({
2057
+ type: 'finish',
2058
+ finishReason: { unified: 'stop' },
2059
+ usage: {
2060
+ inputTokens: { total: 6 },
2061
+ outputTokens: { total: 3 },
2062
+ },
2063
+ providerMetadata: {
2064
+ 'claude-code': {
2065
+ sessionId: 'json-session-1',
2066
+ costUsd: 0.001,
2067
+ durationMs: 1000,
2068
+ },
2069
+ },
2070
+ });
2071
+
2072
+ // Warnings are now included in the stream-start event
2073
+ expect(chunks[0].warnings).toHaveLength(1);
2074
+ expect(chunks[0].warnings?.[0]).toMatchObject({
2075
+ type: 'unsupported',
2076
+ feature: 'temperature',
2077
+ });
2078
+
2079
+ // Verify outputFormat was passed to SDK
2080
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as {
2081
+ options: { outputFormat?: { type: string; schema: unknown } };
2082
+ };
2083
+ expect(call.options?.outputFormat).toEqual({
2084
+ type: 'json_schema',
2085
+ schema: { type: 'object' },
2086
+ });
2087
+ });
2088
+
2089
+ it('should handle structured output error from SDK', async () => {
2090
+ // SDK 0.1.45+ returns error_max_structured_output_retries when it can't produce valid output
2091
+ const mockResponse = {
2092
+ async *[Symbol.asyncIterator]() {
2093
+ yield {
2094
+ type: 'result',
2095
+ subtype: 'error_max_structured_output_retries',
2096
+ session_id: 'json-error-session',
2097
+ usage: {
2098
+ input_tokens: 8,
2099
+ output_tokens: 5,
2100
+ },
2101
+ };
2102
+ },
2103
+ };
2104
+
2105
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
2106
+
2107
+ const result = await model.doStream({
2108
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Return invalid JSON' }] }],
2109
+ responseFormat: { type: 'json', schema: { type: 'object' } },
2110
+ });
2111
+
2112
+ const chunks: any[] = [];
2113
+ const reader = result.stream.getReader();
2114
+
2115
+ while (true) {
2116
+ const { done, value } = await reader.read();
2117
+ if (done) break;
2118
+ chunks.push(value);
2119
+ }
2120
+
2121
+ // Should emit stream-start and then error
2122
+ expect(chunks).toHaveLength(2);
2123
+ expect(chunks[0]).toMatchObject({
2124
+ type: 'stream-start',
2125
+ });
2126
+ expect(chunks[1]).toMatchObject({
2127
+ type: 'error',
2128
+ });
2129
+ expect(chunks[1].error.message).toContain('structured output');
2130
+ });
2131
+
2132
+ it('should warn and treat as plain text when JSON mode requested without schema', async () => {
2133
+ // When responseFormat.type === 'json' but no schema is provided,
2134
+ // Claude Code (like Anthropic) does not support JSON-without-schema.
2135
+ // We emit an unsupported-setting warning and treat as plain text.
2136
+ const plainText = 'Here is some text that happens to look like JSON: {"name": "test"}';
2137
+ const mockResponse = {
2138
+ async *[Symbol.asyncIterator]() {
2139
+ yield {
2140
+ type: 'assistant',
2141
+ message: {
2142
+ content: [{ type: 'text', text: plainText }],
2143
+ },
2144
+ };
2145
+ yield {
2146
+ type: 'result',
2147
+ subtype: 'success',
2148
+ session_id: 'json-no-schema-session',
2149
+ // No structured_output field - SDK didn't use outputFormat
2150
+ usage: {
2151
+ input_tokens: 10,
2152
+ output_tokens: 15,
2153
+ },
2154
+ total_cost_usd: 0.002,
2155
+ duration_ms: 200,
2156
+ };
2157
+ },
2158
+ };
2159
+
2160
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
2161
+
2162
+ const result = await model.doStream({
2163
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Return JSON' }] }],
2164
+ responseFormat: { type: 'json' }, // No schema provided
2165
+ });
2166
+
2167
+ const chunks: any[] = [];
2168
+ const reader = result.stream.getReader();
2169
+
2170
+ while (true) {
2171
+ const { done, value } = await reader.read();
2172
+ if (done) break;
2173
+ chunks.push(value);
2174
+ }
2175
+
2176
+ // Should emit: stream-start (with warning), text-start, text-delta, text-end, finish
2177
+ expect(chunks).toHaveLength(5);
2178
+
2179
+ // Verify unsupported warning is emitted
2180
+ expect(chunks[0]).toMatchObject({
2181
+ type: 'stream-start',
2182
+ });
2183
+ const streamStartWarnings = chunks[0].warnings;
2184
+ expect(streamStartWarnings).toEqual(
2185
+ expect.arrayContaining([
2186
+ expect.objectContaining({
2187
+ type: 'unsupported',
2188
+ feature: 'responseFormat',
2189
+ details: expect.stringContaining('requires a schema'),
2190
+ }),
2191
+ ])
2192
+ );
2193
+
2194
+ // Verify response is plain text (not parsed or modified)
2195
+ expect(chunks[1]).toMatchObject({
2196
+ type: 'text-start',
2197
+ });
2198
+ expect(chunks[2]).toMatchObject({
2199
+ type: 'text-delta',
2200
+ delta: plainText,
2201
+ });
2202
+ expect(chunks[3]).toMatchObject({
2203
+ type: 'text-end',
2204
+ });
2205
+ expect(chunks[4]).toMatchObject({
2206
+ type: 'finish',
2207
+ finishReason: { unified: 'stop' },
2208
+ usage: {
2209
+ inputTokens: { total: 10 },
2210
+ outputTokens: { total: 15 },
2211
+ },
2212
+ });
2213
+
2214
+ // Verify outputFormat was NOT passed to SDK (no schema)
2215
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as {
2216
+ options: { outputFormat?: unknown };
2217
+ };
2218
+ expect(call.options?.outputFormat).toBeUndefined();
2219
+ });
2220
+
2221
+ it('emits tool streaming events for provider-executed tools', async () => {
2222
+ const toolUseId = 'toolu_123';
2223
+ const toolName = 'list_directory';
2224
+ const toolInput = { command: 'ls', args: ['-lah'] };
2225
+ const toolResultPayload = JSON.stringify([
2226
+ { name: 'README.md', size: 1024 },
2227
+ { name: 'package.json', size: 2048 },
2228
+ ]);
2229
+
2230
+ const mockResponse = {
2231
+ async *[Symbol.asyncIterator]() {
2232
+ yield {
2233
+ type: 'assistant',
2234
+ message: {
2235
+ content: [
2236
+ {
2237
+ type: 'tool_use',
2238
+ id: toolUseId,
2239
+ name: toolName,
2240
+ input: toolInput,
2241
+ },
2242
+ ],
2243
+ },
2244
+ };
2245
+ yield {
2246
+ type: 'user',
2247
+ message: {
2248
+ content: [
2249
+ {
2250
+ type: 'tool_result',
2251
+ tool_use_id: toolUseId,
2252
+ name: toolName,
2253
+ content: toolResultPayload,
2254
+ is_error: false,
2255
+ },
2256
+ ],
2257
+ },
2258
+ };
2259
+ yield {
2260
+ type: 'result',
2261
+ subtype: 'success',
2262
+ session_id: 'tool-session',
2263
+ usage: {
2264
+ input_tokens: 12,
2265
+ output_tokens: 3,
2266
+ },
2267
+ total_cost_usd: 0.002,
2268
+ duration_ms: 500,
2269
+ };
2270
+ },
2271
+ };
2272
+
2273
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
2274
+
2275
+ const { stream } = await model.doStream({
2276
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'List files' }] }],
2277
+ });
2278
+
2279
+ const events: ExtendedStreamPart[] = [];
2280
+ const reader = stream.getReader();
2281
+
2282
+ while (true) {
2283
+ const { done, value } = await reader.read();
2284
+ if (done) break;
2285
+ events.push(value);
2286
+ }
2287
+
2288
+ const toolInputStart = events.find((event) => event.type === 'tool-input-start');
2289
+ const toolInputDelta = events.find((event) => event.type === 'tool-input-delta');
2290
+ const toolInputEnd = events.find((event) => event.type === 'tool-input-end');
2291
+ const toolCall = events.find((event) => event.type === 'tool-call');
2292
+ const toolResult = events.find((event) => event.type === 'tool-result');
2293
+
2294
+ expect(toolInputStart).toMatchObject({
2295
+ type: 'tool-input-start',
2296
+ id: toolUseId,
2297
+ toolName,
2298
+ providerExecuted: true,
2299
+ });
2300
+
2301
+ expect(toolInputDelta).toMatchObject({
2302
+ type: 'tool-input-delta',
2303
+ id: toolUseId,
2304
+ delta: JSON.stringify(toolInput),
2305
+ });
2306
+
2307
+ expect(toolInputEnd).toMatchObject({
2308
+ type: 'tool-input-end',
2309
+ id: toolUseId,
2310
+ });
2311
+
2312
+ expect(events.indexOf(toolInputDelta!)).toBeLessThan(events.indexOf(toolInputEnd!));
2313
+
2314
+ expect(toolCall).toMatchObject({
2315
+ type: 'tool-call',
2316
+ toolCallId: toolUseId,
2317
+ toolName,
2318
+ input: JSON.stringify(toolInput),
2319
+ providerExecuted: true,
2320
+ providerMetadata: {
2321
+ 'claude-code': {
2322
+ rawInput: JSON.stringify(toolInput),
2323
+ },
2324
+ },
2325
+ });
2326
+
2327
+ expect(events.indexOf(toolInputEnd!)).toBeLessThan(events.indexOf(toolCall!));
2328
+ expect(events.indexOf(toolCall!)).toBeLessThan(events.indexOf(toolResult!));
2329
+
2330
+ expect(toolResult).toMatchObject({
2331
+ type: 'tool-result',
2332
+ toolCallId: toolUseId,
2333
+ toolName,
2334
+ result: JSON.parse(toolResultPayload),
2335
+ providerExecuted: true,
2336
+ isError: false,
2337
+ providerMetadata: {
2338
+ 'claude-code': {
2339
+ rawResult: toolResultPayload,
2340
+ },
2341
+ },
2342
+ });
2343
+ });
2344
+
2345
+ it('propagates parent_tool_use_id into tool stream metadata', async () => {
2346
+ const parentToolId = 'toolu_task_parent';
2347
+ const toolUseId = 'toolu_child';
2348
+ const toolName = 'Bash';
2349
+ const toolInput = { command: 'echo "hi"' };
2350
+
2351
+ const mockResponse = {
2352
+ async *[Symbol.asyncIterator]() {
2353
+ yield {
2354
+ type: 'assistant',
2355
+ parent_tool_use_id: parentToolId,
2356
+ message: {
2357
+ content: [
2358
+ {
2359
+ type: 'tool_use',
2360
+ id: toolUseId,
2361
+ name: toolName,
2362
+ input: toolInput,
2363
+ },
2364
+ ],
2365
+ },
2366
+ };
2367
+ yield {
2368
+ type: 'user',
2369
+ parent_tool_use_id: parentToolId,
2370
+ message: {
2371
+ content: [
2372
+ {
2373
+ type: 'tool_result',
2374
+ tool_use_id: toolUseId,
2375
+ name: toolName,
2376
+ content: 'ok',
2377
+ is_error: false,
2378
+ },
2379
+ ],
2380
+ },
2381
+ };
2382
+ yield {
2383
+ type: 'result',
2384
+ subtype: 'success',
2385
+ session_id: 'tool-parent-session',
2386
+ usage: {
2387
+ input_tokens: 1,
2388
+ output_tokens: 1,
2389
+ },
2390
+ };
2391
+ },
2392
+ };
2393
+
2394
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
2395
+
2396
+ const { stream } = await model.doStream({
2397
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Run command' }] }],
2398
+ });
2399
+
2400
+ const events: ExtendedStreamPart[] = [];
2401
+ const reader = stream.getReader();
2402
+ while (true) {
2403
+ const { done, value } = await reader.read();
2404
+ if (done) break;
2405
+ events.push(value);
2406
+ }
2407
+
2408
+ const toolInputStart = events.find((event) => event.type === 'tool-input-start') as
2409
+ | (ExtendedStreamPart & {
2410
+ type: 'tool-input-start';
2411
+ providerMetadata?: Record<string, unknown>;
2412
+ })
2413
+ | undefined;
2414
+ const toolCall = events.find((event) => event.type === 'tool-call') as
2415
+ | (ExtendedStreamPart & { type: 'tool-call'; providerMetadata?: Record<string, unknown> })
2416
+ | undefined;
2417
+ const toolResult = events.find((event) => event.type === 'tool-result') as
2418
+ | (ExtendedStreamPart & { type: 'tool-result'; providerMetadata?: Record<string, unknown> })
2419
+ | undefined;
2420
+
2421
+ expect(toolInputStart).toMatchObject({
2422
+ type: 'tool-input-start',
2423
+ id: toolUseId,
2424
+ providerMetadata: {
2425
+ 'claude-code': {
2426
+ parentToolCallId: parentToolId,
2427
+ },
2428
+ },
2429
+ });
2430
+
2431
+ expect(toolCall).toMatchObject({
2432
+ type: 'tool-call',
2433
+ toolCallId: toolUseId,
2434
+ providerMetadata: {
2435
+ 'claude-code': {
2436
+ parentToolCallId: parentToolId,
2437
+ },
2438
+ },
2439
+ });
2440
+
2441
+ expect(toolResult).toMatchObject({
2442
+ type: 'tool-result',
2443
+ toolCallId: toolUseId,
2444
+ providerMetadata: {
2445
+ 'claude-code': {
2446
+ parentToolCallId: parentToolId,
2447
+ },
2448
+ },
2449
+ });
2450
+ });
2451
+
2452
+ it('infers parentToolCallId from a single active Task tool', async () => {
2453
+ const taskToolId = 'toolu_task';
2454
+ const childToolId = 'toolu_child_inferred';
2455
+ const childToolName = 'Bash';
2456
+
2457
+ const mockResponse = {
2458
+ async *[Symbol.asyncIterator]() {
2459
+ yield {
2460
+ type: 'assistant',
2461
+ message: {
2462
+ content: [
2463
+ {
2464
+ type: 'tool_use',
2465
+ id: taskToolId,
2466
+ name: 'Task',
2467
+ input: { objective: 'Run command' },
2468
+ },
2469
+ {
2470
+ type: 'tool_use',
2471
+ id: childToolId,
2472
+ name: childToolName,
2473
+ input: { command: 'ls' },
2474
+ },
2475
+ ],
2476
+ },
2477
+ };
2478
+ yield {
2479
+ type: 'user',
2480
+ message: {
2481
+ content: [
2482
+ {
2483
+ type: 'tool_result',
2484
+ tool_use_id: childToolId,
2485
+ name: childToolName,
2486
+ content: 'done',
2487
+ is_error: false,
2488
+ },
2489
+ ],
2490
+ },
2491
+ };
2492
+ yield {
2493
+ type: 'result',
2494
+ subtype: 'success',
2495
+ session_id: 'tool-fallback-session',
2496
+ usage: {
2497
+ input_tokens: 2,
2498
+ output_tokens: 1,
2499
+ },
2500
+ };
2501
+ },
2502
+ };
2503
+
2504
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
2505
+
2506
+ const { stream } = await model.doStream({
2507
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Do work' }] }],
2508
+ });
2509
+
2510
+ const events: ExtendedStreamPart[] = [];
2511
+ const reader = stream.getReader();
2512
+ while (true) {
2513
+ const { done, value } = await reader.read();
2514
+ if (done) break;
2515
+ events.push(value);
2516
+ }
2517
+
2518
+ const toolCall = events.find(
2519
+ (event) => event.type === 'tool-call' && (event as any).toolCallId === childToolId
2520
+ ) as ExtendedStreamPart | undefined;
2521
+ const toolResult = events.find(
2522
+ (event) => event.type === 'tool-result' && (event as any).toolCallId === childToolId
2523
+ ) as ExtendedStreamPart | undefined;
2524
+
2525
+ expect(toolCall).toMatchObject({
2526
+ type: 'tool-call',
2527
+ toolCallId: childToolId,
2528
+ providerMetadata: {
2529
+ 'claude-code': {
2530
+ parentToolCallId: taskToolId,
2531
+ },
2532
+ },
2533
+ });
2534
+
2535
+ expect(toolResult).toMatchObject({
2536
+ type: 'tool-result',
2537
+ toolCallId: childToolId,
2538
+ providerMetadata: {
2539
+ 'claude-code': {
2540
+ parentToolCallId: taskToolId,
2541
+ },
2542
+ },
2543
+ });
2544
+ });
2545
+
2546
+ it('does not infer parentToolCallId when multiple Task tools are active', async () => {
2547
+ const taskToolIdA = 'toolu_task_a';
2548
+ const taskToolIdB = 'toolu_task_b';
2549
+ const childToolId = 'toolu_child_ambiguous';
2550
+ const childToolName = 'Bash';
2551
+
2552
+ const mockResponse = {
2553
+ async *[Symbol.asyncIterator]() {
2554
+ yield {
2555
+ type: 'assistant',
2556
+ message: {
2557
+ content: [
2558
+ {
2559
+ type: 'tool_use',
2560
+ id: taskToolIdA,
2561
+ name: 'Task',
2562
+ input: { objective: 'Task A' },
2563
+ },
2564
+ {
2565
+ type: 'tool_use',
2566
+ id: taskToolIdB,
2567
+ name: 'Task',
2568
+ input: { objective: 'Task B' },
2569
+ },
2570
+ {
2571
+ type: 'tool_use',
2572
+ id: childToolId,
2573
+ name: childToolName,
2574
+ input: { command: 'pwd' },
2575
+ },
2576
+ ],
2577
+ },
2578
+ };
2579
+ yield {
2580
+ type: 'user',
2581
+ message: {
2582
+ content: [
2583
+ {
2584
+ type: 'tool_result',
2585
+ tool_use_id: childToolId,
2586
+ name: childToolName,
2587
+ content: 'ok',
2588
+ is_error: false,
2589
+ },
2590
+ ],
2591
+ },
2592
+ };
2593
+ yield {
2594
+ type: 'result',
2595
+ subtype: 'success',
2596
+ session_id: 'tool-ambiguous-session',
2597
+ usage: {
2598
+ input_tokens: 3,
2599
+ output_tokens: 1,
2600
+ },
2601
+ };
2602
+ },
2603
+ };
2604
+
2605
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
2606
+
2607
+ const { stream } = await model.doStream({
2608
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Do work' }] }],
2609
+ });
2610
+
2611
+ const events: ExtendedStreamPart[] = [];
2612
+ const reader = stream.getReader();
2613
+ while (true) {
2614
+ const { done, value } = await reader.read();
2615
+ if (done) break;
2616
+ events.push(value);
2617
+ }
2618
+
2619
+ const toolCall = events.find(
2620
+ (event) => event.type === 'tool-call' && (event as any).toolCallId === childToolId
2621
+ ) as ExtendedStreamPart | undefined;
2622
+
2623
+ expect(toolCall).toMatchObject({
2624
+ type: 'tool-call',
2625
+ toolCallId: childToolId,
2626
+ providerMetadata: {
2627
+ 'claude-code': {
2628
+ parentToolCallId: null,
2629
+ },
2630
+ },
2631
+ });
2632
+ });
2633
+
2634
+ it('normalizes MCP text content arrays into structured results', async () => {
2635
+ const toolUseId = 'toolu_mcp_text';
2636
+ const toolName = 'mcp_tool';
2637
+ const toolInput = { query: 'status' };
2638
+ const toolResultContent = [
2639
+ { type: 'text', text: '{ "foo": "bar",' },
2640
+ { type: 'text', text: '"baz": 1 }' },
2641
+ ];
2642
+
2643
+ const mockResponse = {
2644
+ async *[Symbol.asyncIterator]() {
2645
+ yield {
2646
+ type: 'assistant',
2647
+ message: {
2648
+ content: [
2649
+ {
2650
+ type: 'tool_use',
2651
+ id: toolUseId,
2652
+ name: toolName,
2653
+ input: toolInput,
2654
+ },
2655
+ ],
2656
+ },
2657
+ };
2658
+ yield {
2659
+ type: 'user',
2660
+ message: {
2661
+ content: [
2662
+ {
2663
+ type: 'tool_result',
2664
+ tool_use_id: toolUseId,
2665
+ name: toolName,
2666
+ content: toolResultContent,
2667
+ is_error: false,
2668
+ },
2669
+ ],
2670
+ },
2671
+ };
2672
+ yield {
2673
+ type: 'result',
2674
+ subtype: 'success',
2675
+ session_id: 'tool-session-text',
2676
+ usage: {
2677
+ input_tokens: 4,
2678
+ output_tokens: 2,
2679
+ },
2680
+ total_cost_usd: 0.001,
2681
+ duration_ms: 120,
2682
+ };
2683
+ },
2684
+ };
2685
+
2686
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
2687
+
2688
+ const { stream } = await model.doStream({
2689
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Summarize' }] }],
2690
+ });
2691
+
2692
+ const events: ExtendedStreamPart[] = [];
2693
+ const reader = stream.getReader();
2694
+
2695
+ while (true) {
2696
+ const { done, value } = await reader.read();
2697
+ if (done) break;
2698
+ events.push(value);
2699
+ }
2700
+
2701
+ const toolResult = events.find((event) => event.type === 'tool-result') as
2702
+ | (ExtendedStreamPart & { type: 'tool-result'; result: unknown })
2703
+ | undefined;
2704
+
2705
+ expect(toolResult).toBeDefined();
2706
+ expect(toolResult?.result).toEqual({ foo: 'bar', baz: 1 });
2707
+ });
2708
+
2709
+ it('preserves non-text MCP content blocks in tool results', async () => {
2710
+ const toolUseId = 'toolu_mcp_mixed';
2711
+ const toolName = 'mcp_tool';
2712
+ const toolInput = { query: 'image' };
2713
+ const toolResultContent = [
2714
+ { type: 'text', text: 'Here is an image' },
2715
+ { type: 'image', data: 'aGVsbG8=', mimeType: 'image/png' },
2716
+ ];
2717
+
2718
+ const mockResponse = {
2719
+ async *[Symbol.asyncIterator]() {
2720
+ yield {
2721
+ type: 'assistant',
2722
+ message: {
2723
+ content: [
2724
+ {
2725
+ type: 'tool_use',
2726
+ id: toolUseId,
2727
+ name: toolName,
2728
+ input: toolInput,
2729
+ },
2730
+ ],
2731
+ },
2732
+ };
2733
+ yield {
2734
+ type: 'user',
2735
+ message: {
2736
+ content: [
2737
+ {
2738
+ type: 'tool_result',
2739
+ tool_use_id: toolUseId,
2740
+ name: toolName,
2741
+ content: toolResultContent,
2742
+ is_error: false,
2743
+ },
2744
+ ],
2745
+ },
2746
+ };
2747
+ yield {
2748
+ type: 'result',
2749
+ subtype: 'success',
2750
+ session_id: 'tool-session-mixed',
2751
+ usage: {
2752
+ input_tokens: 6,
2753
+ output_tokens: 3,
2754
+ },
2755
+ total_cost_usd: 0.0015,
2756
+ duration_ms: 140,
2757
+ };
2758
+ },
2759
+ };
2760
+
2761
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
2762
+
2763
+ const { stream } = await model.doStream({
2764
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Show image' }] }],
2765
+ });
2766
+
2767
+ const events: ExtendedStreamPart[] = [];
2768
+ const reader = stream.getReader();
2769
+
2770
+ while (true) {
2771
+ const { done, value } = await reader.read();
2772
+ if (done) break;
2773
+ events.push(value);
2774
+ }
2775
+
2776
+ const toolResult = events.find((event) => event.type === 'tool-result') as
2777
+ | (ExtendedStreamPart & { type: 'tool-result'; result: unknown })
2778
+ | undefined;
2779
+
2780
+ expect(toolResult).toBeDefined();
2781
+ expect(toolResult?.result).toEqual(toolResultContent);
2782
+ });
2783
+
2784
+ it('truncates long string tool results in stream metadata', async () => {
2785
+ const maxToolResultSize = 100;
2786
+ const modelWithLimit = new ClaudeCodeLanguageModel({
2787
+ id: 'sonnet',
2788
+ settings: { maxToolResultSize },
2789
+ });
2790
+ const toolUseId = 'toolu_truncate_string';
2791
+ const toolName = 'Read';
2792
+ const toolInput = { file_path: '/tmp/example.txt' };
2793
+ const longText = 'x'.repeat(maxToolResultSize + 15);
2794
+ const truncatedText = `${longText.slice(0, maxToolResultSize)}\n...[truncated ${
2795
+ longText.length - maxToolResultSize
2796
+ } chars]`;
2797
+
2798
+ const mockResponse = {
2799
+ async *[Symbol.asyncIterator]() {
2800
+ yield {
2801
+ type: 'assistant',
2802
+ message: {
2803
+ content: [
2804
+ {
2805
+ type: 'tool_use',
2806
+ id: toolUseId,
2807
+ name: toolName,
2808
+ input: toolInput,
2809
+ },
2810
+ ],
2811
+ },
2812
+ };
2813
+ yield {
2814
+ type: 'user',
2815
+ message: {
2816
+ content: [
2817
+ {
2818
+ type: 'tool_result',
2819
+ tool_use_id: toolUseId,
2820
+ name: toolName,
2821
+ content: longText,
2822
+ is_error: false,
2823
+ },
2824
+ ],
2825
+ },
2826
+ };
2827
+ yield {
2828
+ type: 'result',
2829
+ subtype: 'success',
2830
+ session_id: 'tool-session-truncate-string',
2831
+ usage: {
2832
+ input_tokens: 4,
2833
+ output_tokens: 2,
2834
+ },
2835
+ total_cost_usd: 0.001,
2836
+ duration_ms: 80,
2837
+ };
2838
+ },
2839
+ };
2840
+
2841
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
2842
+
2843
+ const { stream } = await modelWithLimit.doStream({
2844
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Read file' }] }],
2845
+ });
2846
+
2847
+ const events: ExtendedStreamPart[] = [];
2848
+ const reader = stream.getReader();
2849
+
2850
+ while (true) {
2851
+ const { done, value } = await reader.read();
2852
+ if (done) break;
2853
+ events.push(value);
2854
+ }
2855
+
2856
+ const toolResult = events.find((event) => event.type === 'tool-result') as
2857
+ | (ExtendedStreamPart & { type: 'tool-result'; result: unknown })
2858
+ | undefined;
2859
+
2860
+ const metadata = toolResult?.providerMetadata?.['claude-code'] as
2861
+ | { rawResult?: string; rawResultTruncated?: boolean }
2862
+ | undefined;
2863
+
2864
+ expect(toolResult?.result).toBe(truncatedText);
2865
+ expect(metadata?.rawResult).toBe(truncatedText);
2866
+ expect(metadata?.rawResultTruncated).toBe(true);
2867
+ });
2868
+
2869
+ it('truncates the largest string field in object tool results', async () => {
2870
+ const maxToolResultSize = 100;
2871
+ const modelWithLimit = new ClaudeCodeLanguageModel({
2872
+ id: 'sonnet',
2873
+ settings: { maxToolResultSize },
2874
+ });
2875
+ const toolUseId = 'toolu_truncate_object';
2876
+ const toolName = 'Read';
2877
+ const toolInput = { file_path: '/tmp/example.txt' };
2878
+ const longText = 'y'.repeat(maxToolResultSize + 18);
2879
+ const truncatedText = `${longText.slice(0, maxToolResultSize)}\n...[truncated ${
2880
+ longText.length - maxToolResultSize
2881
+ } chars]`;
2882
+ const toolResultContent = { short: 'ok', long: longText };
2883
+
2884
+ const mockResponse = {
2885
+ async *[Symbol.asyncIterator]() {
2886
+ yield {
2887
+ type: 'assistant',
2888
+ message: {
2889
+ content: [
2890
+ {
2891
+ type: 'tool_use',
2892
+ id: toolUseId,
2893
+ name: toolName,
2894
+ input: toolInput,
2895
+ },
2896
+ ],
2897
+ },
2898
+ };
2899
+ yield {
2900
+ type: 'user',
2901
+ message: {
2902
+ content: [
2903
+ {
2904
+ type: 'tool_result',
2905
+ tool_use_id: toolUseId,
2906
+ name: toolName,
2907
+ content: toolResultContent,
2908
+ is_error: false,
2909
+ },
2910
+ ],
2911
+ },
2912
+ };
2913
+ yield {
2914
+ type: 'result',
2915
+ subtype: 'success',
2916
+ session_id: 'tool-session-truncate-object',
2917
+ usage: {
2918
+ input_tokens: 4,
2919
+ output_tokens: 2,
2920
+ },
2921
+ total_cost_usd: 0.001,
2922
+ duration_ms: 80,
2923
+ };
2924
+ },
2925
+ };
2926
+
2927
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
2928
+
2929
+ const { stream } = await modelWithLimit.doStream({
2930
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Read file' }] }],
2931
+ });
2932
+
2933
+ const events: ExtendedStreamPart[] = [];
2934
+ const reader = stream.getReader();
2935
+
2936
+ while (true) {
2937
+ const { done, value } = await reader.read();
2938
+ if (done) break;
2939
+ events.push(value);
2940
+ }
2941
+
2942
+ const toolResult = events.find((event) => event.type === 'tool-result') as
2943
+ | (ExtendedStreamPart & { type: 'tool-result'; result: unknown })
2944
+ | undefined;
2945
+
2946
+ const metadata = toolResult?.providerMetadata?.['claude-code'] as
2947
+ | { rawResultTruncated?: boolean }
2948
+ | undefined;
2949
+
2950
+ expect(toolResult?.result).toEqual({ short: 'ok', long: truncatedText });
2951
+ expect(metadata?.rawResultTruncated).toBe(true);
2952
+ });
2953
+
2954
+ it('truncates the largest string element in array tool results', async () => {
2955
+ const maxToolResultSize = 100;
2956
+ const modelWithLimit = new ClaudeCodeLanguageModel({
2957
+ id: 'sonnet',
2958
+ settings: { maxToolResultSize },
2959
+ });
2960
+ const toolUseId = 'toolu_truncate_array';
2961
+ const toolName = 'Read';
2962
+ const toolInput = { file_path: '/tmp/example.txt' };
2963
+ const longText = 'z'.repeat(maxToolResultSize + 14);
2964
+ const truncatedText = `${longText.slice(0, maxToolResultSize)}\n...[truncated ${
2965
+ longText.length - maxToolResultSize
2966
+ } chars]`;
2967
+ const toolResultContent = ['ok', longText, 'done'];
2968
+
2969
+ const mockResponse = {
2970
+ async *[Symbol.asyncIterator]() {
2971
+ yield {
2972
+ type: 'assistant',
2973
+ message: {
2974
+ content: [
2975
+ {
2976
+ type: 'tool_use',
2977
+ id: toolUseId,
2978
+ name: toolName,
2979
+ input: toolInput,
2980
+ },
2981
+ ],
2982
+ },
2983
+ };
2984
+ yield {
2985
+ type: 'user',
2986
+ message: {
2987
+ content: [
2988
+ {
2989
+ type: 'tool_result',
2990
+ tool_use_id: toolUseId,
2991
+ name: toolName,
2992
+ content: toolResultContent,
2993
+ is_error: false,
2994
+ },
2995
+ ],
2996
+ },
2997
+ };
2998
+ yield {
2999
+ type: 'result',
3000
+ subtype: 'success',
3001
+ session_id: 'tool-session-truncate-array',
3002
+ usage: {
3003
+ input_tokens: 4,
3004
+ output_tokens: 2,
3005
+ },
3006
+ total_cost_usd: 0.001,
3007
+ duration_ms: 80,
3008
+ };
3009
+ },
3010
+ };
3011
+
3012
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3013
+
3014
+ const { stream } = await modelWithLimit.doStream({
3015
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Read file' }] }],
3016
+ });
3017
+
3018
+ const events: ExtendedStreamPart[] = [];
3019
+ const reader = stream.getReader();
3020
+
3021
+ while (true) {
3022
+ const { done, value } = await reader.read();
3023
+ if (done) break;
3024
+ events.push(value);
3025
+ }
3026
+
3027
+ const toolResult = events.find((event) => event.type === 'tool-result') as
3028
+ | (ExtendedStreamPart & { type: 'tool-result'; result: unknown })
3029
+ | undefined;
3030
+
3031
+ const metadata = toolResult?.providerMetadata?.['claude-code'] as
3032
+ | { rawResultTruncated?: boolean }
3033
+ | undefined;
3034
+
3035
+ expect(toolResult?.result).toEqual(['ok', truncatedText, 'done']);
3036
+ expect(metadata?.rawResultTruncated).toBe(true);
3037
+ });
3038
+
3039
+ it('finalizes tool calls even when no tool result is emitted', async () => {
3040
+ const toolUseId = 'toolu_missing_result';
3041
+ const toolName = 'Read';
3042
+
3043
+ const mockResponse = {
3044
+ async *[Symbol.asyncIterator]() {
3045
+ yield {
3046
+ type: 'assistant',
3047
+ message: {
3048
+ content: [
3049
+ {
3050
+ type: 'tool_use',
3051
+ id: toolUseId,
3052
+ name: toolName,
3053
+ input: { file_path: '/tmp/example.txt' },
3054
+ },
3055
+ ],
3056
+ },
3057
+ };
3058
+ yield {
3059
+ type: 'result',
3060
+ subtype: 'success',
3061
+ session_id: 'session-missing-result',
3062
+ usage: {
3063
+ input_tokens: 5,
3064
+ output_tokens: 0,
3065
+ },
3066
+ total_cost_usd: 0,
3067
+ duration_ms: 10,
3068
+ };
3069
+ },
3070
+ };
3071
+
3072
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3073
+
3074
+ const { stream } = await model.doStream({
3075
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Read file' }] }],
3076
+ });
3077
+
3078
+ const events: ExtendedStreamPart[] = [];
3079
+ const reader = stream.getReader();
3080
+ while (true) {
3081
+ const { done, value } = await reader.read();
3082
+ if (done) break;
3083
+ events.push(value);
3084
+ }
3085
+
3086
+ const toolInputStartIndex = events.findIndex((event) => event.type === 'tool-input-start');
3087
+ const toolInputEndIndex = events.findIndex((event) => event.type === 'tool-input-end');
3088
+ const toolCallIndex = events.findIndex((event) => event.type === 'tool-call');
3089
+ const toolResultIndex = events.findIndex((event) => event.type === 'tool-result');
3090
+ const finishIndex = events.findIndex((event) => event.type === 'finish');
3091
+
3092
+ expect(toolInputStartIndex).toBeGreaterThan(-1);
3093
+ expect(toolInputEndIndex).toBeGreaterThan(toolInputStartIndex);
3094
+ expect(toolCallIndex).toBeGreaterThan(toolInputEndIndex);
3095
+ expect(toolResultIndex).toBe(-1);
3096
+ expect(finishIndex).toBeGreaterThan(toolCallIndex);
3097
+
3098
+ const toolCallEvent = events[toolCallIndex];
3099
+ expect(toolCallEvent).toMatchObject({
3100
+ type: 'tool-call',
3101
+ toolCallId: toolUseId,
3102
+ toolName,
3103
+ input: JSON.stringify({ file_path: '/tmp/example.txt' }),
3104
+ providerExecuted: true,
3105
+ });
3106
+ });
3107
+
3108
+ it('emits tool-error events for tool failures and orders after tool-call', async () => {
3109
+ const toolUseId = 'toolu_error';
3110
+ const toolName = 'Read';
3111
+ const errorMessage = 'File not found: /nonexistent.txt';
3112
+
3113
+ const mockResponse = {
3114
+ async *[Symbol.asyncIterator]() {
3115
+ yield {
3116
+ type: 'assistant',
3117
+ message: {
3118
+ content: [
3119
+ {
3120
+ type: 'tool_use',
3121
+ id: toolUseId,
3122
+ name: toolName,
3123
+ input: { file_path: '/nonexistent.txt' },
3124
+ },
3125
+ ],
3126
+ },
3127
+ };
3128
+ yield {
3129
+ type: 'user',
3130
+ message: {
3131
+ content: [
3132
+ {
3133
+ type: 'tool_error',
3134
+ tool_use_id: toolUseId,
3135
+ name: toolName,
3136
+ error: errorMessage,
3137
+ },
3138
+ ],
3139
+ },
3140
+ };
3141
+ yield {
3142
+ type: 'result',
3143
+ subtype: 'success',
3144
+ session_id: 'error-session',
3145
+ usage: { input_tokens: 10, output_tokens: 0 },
3146
+ total_cost_usd: 0.001,
3147
+ duration_ms: 100,
3148
+ };
3149
+ },
3150
+ };
3151
+
3152
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3153
+
3154
+ const { stream } = await model.doStream({
3155
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Read missing file' }] }],
3156
+ } as any);
3157
+
3158
+ const events: ExtendedStreamPart[] = [];
3159
+ const reader = stream.getReader();
3160
+
3161
+ while (true) {
3162
+ const { done, value } = await reader.read();
3163
+ if (done) break;
3164
+ events.push(value);
3165
+ }
3166
+
3167
+ const toolError = events.find((e) => e.type === 'tool-error');
3168
+ const toolCall = events.find((e) => e.type === 'tool-call');
3169
+
3170
+ expect(toolCall).toMatchObject({
3171
+ type: 'tool-call',
3172
+ toolCallId: toolUseId,
3173
+ toolName,
3174
+ providerExecuted: true,
3175
+ });
3176
+
3177
+ expect(toolError).toMatchObject({
3178
+ type: 'tool-error',
3179
+ toolCallId: toolUseId,
3180
+ toolName,
3181
+ error: errorMessage,
3182
+ providerExecuted: true,
3183
+ });
3184
+
3185
+ expect(events.indexOf(toolCall!)).toBeLessThan(events.indexOf(toolError!));
3186
+ });
3187
+
3188
+ it('emits only one tool-call for multiple tool-result chunks', async () => {
3189
+ const toolUseId = 'toolu_chunked';
3190
+ const toolName = 'Bash';
3191
+
3192
+ const mockResponse = {
3193
+ async *[Symbol.asyncIterator]() {
3194
+ yield {
3195
+ type: 'assistant',
3196
+ message: {
3197
+ content: [
3198
+ {
3199
+ type: 'tool_use',
3200
+ id: toolUseId,
3201
+ name: toolName,
3202
+ input: { command: 'echo "test"' },
3203
+ },
3204
+ ],
3205
+ },
3206
+ };
3207
+ // First result chunk
3208
+ yield {
3209
+ type: 'user',
3210
+ message: {
3211
+ content: [
3212
+ {
3213
+ type: 'tool_result',
3214
+ tool_use_id: toolUseId,
3215
+ name: toolName,
3216
+ content: 'Chunk 1\n',
3217
+ is_error: false,
3218
+ },
3219
+ ],
3220
+ },
3221
+ };
3222
+ // Second result chunk - same tool_use_id
3223
+ yield {
3224
+ type: 'user',
3225
+ message: {
3226
+ content: [
3227
+ {
3228
+ type: 'tool_result',
3229
+ tool_use_id: toolUseId,
3230
+ name: toolName,
3231
+ content: 'Chunk 2\n',
3232
+ is_error: false,
3233
+ },
3234
+ ],
3235
+ },
3236
+ };
3237
+ yield {
3238
+ type: 'result',
3239
+ subtype: 'success',
3240
+ session_id: 'chunked-session',
3241
+ usage: { input_tokens: 15, output_tokens: 5 },
3242
+ total_cost_usd: 0.002,
3243
+ duration_ms: 200,
3244
+ };
3245
+ },
3246
+ };
3247
+
3248
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3249
+
3250
+ const { stream } = await model.doStream({
3251
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Run command' }] }],
3252
+ } as any);
3253
+
3254
+ const events: ExtendedStreamPart[] = [];
3255
+ const reader = stream.getReader();
3256
+
3257
+ while (true) {
3258
+ const { done, value } = await reader.read();
3259
+ if (done) break;
3260
+ events.push(value);
3261
+ }
3262
+
3263
+ const toolCalls = events.filter((e) => e.type === 'tool-call');
3264
+ const toolResults = events.filter((e) => e.type === 'tool-result');
3265
+
3266
+ expect(toolCalls).toHaveLength(1);
3267
+ expect(toolResults).toHaveLength(2);
3268
+ expect(toolCalls[0]).toMatchObject({
3269
+ type: 'tool-call',
3270
+ toolCallId: toolUseId,
3271
+ toolName,
3272
+ });
3273
+ });
3274
+
3275
+ it('synthesizes lifecycle for orphaned tool results (no prior tool_use)', async () => {
3276
+ const toolUseId = 'toolu_orphan';
3277
+ const toolName = 'Read';
3278
+
3279
+ const mockResponse = {
3280
+ async *[Symbol.asyncIterator]() {
3281
+ yield {
3282
+ type: 'user',
3283
+ message: {
3284
+ content: [
3285
+ {
3286
+ type: 'tool_result',
3287
+ tool_use_id: toolUseId,
3288
+ name: toolName,
3289
+ content: 'OK',
3290
+ is_error: false,
3291
+ },
3292
+ ],
3293
+ },
3294
+ };
3295
+ yield {
3296
+ type: 'result',
3297
+ subtype: 'success',
3298
+ session_id: 'orphan-session',
3299
+ usage: { input_tokens: 5, output_tokens: 1 },
3300
+ };
3301
+ },
3302
+ };
3303
+
3304
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3305
+
3306
+ const { stream } = await model.doStream({
3307
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Run' }] }],
3308
+ } as any);
3309
+
3310
+ const events: ExtendedStreamPart[] = [];
3311
+ const reader = stream.getReader();
3312
+ while (true) {
3313
+ const { done, value } = await reader.read();
3314
+ if (done) break;
3315
+ events.push(value);
3316
+ }
3317
+
3318
+ const inputStartIndex = events.findIndex((e) => e.type === 'tool-input-start');
3319
+ const inputEndIndex = events.findIndex((e) => e.type === 'tool-input-end');
3320
+ const callIndex = events.findIndex((e) => e.type === 'tool-call');
3321
+ const resultIndex = events.findIndex((e) => e.type === 'tool-result');
3322
+
3323
+ expect(inputStartIndex).toBeGreaterThan(-1);
3324
+ expect(inputEndIndex).toBeGreaterThan(inputStartIndex);
3325
+ expect(callIndex).toBeGreaterThan(inputEndIndex);
3326
+ expect(resultIndex).toBeGreaterThan(callIndex);
3327
+ });
3328
+
3329
+ it('does not emit delta for non-prefix input updates', async () => {
3330
+ const toolUseId = 'toolu_nonprefix';
3331
+ const toolName = 'TestTool';
3332
+
3333
+ const mockResponse = {
3334
+ async *[Symbol.asyncIterator]() {
3335
+ // First chunk
3336
+ yield {
3337
+ type: 'assistant',
3338
+ message: {
3339
+ content: [
3340
+ { type: 'tool_use', id: toolUseId, name: toolName, input: { arg: 'initial' } },
3341
+ ],
3342
+ },
3343
+ };
3344
+ // Second chunk - non-prefix replacement
3345
+ yield {
3346
+ type: 'assistant',
3347
+ message: {
3348
+ content: [
3349
+ { type: 'tool_use', id: toolUseId, name: toolName, input: { arg: 'replaced' } },
3350
+ ],
3351
+ },
3352
+ };
3353
+ yield {
3354
+ type: 'result',
3355
+ subtype: 'success',
3356
+ session_id: 'nonprefix-session',
3357
+ usage: { input_tokens: 10, output_tokens: 2 },
3358
+ total_cost_usd: 0.001,
3359
+ duration_ms: 50,
3360
+ };
3361
+ },
3362
+ };
3363
+
3364
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3365
+
3366
+ const { stream } = await model.doStream({
3367
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
3368
+ } as any);
3369
+
3370
+ const events: ExtendedStreamPart[] = [];
3371
+ const reader = stream.getReader();
3372
+ while (true) {
3373
+ const { done, value } = await reader.read();
3374
+ if (done) break;
3375
+ events.push(value);
3376
+ }
3377
+
3378
+ const deltas = events.filter((e) => e.type === 'tool-input-delta');
3379
+ const toolCall = events.find((e) => e.type === 'tool-call') as any;
3380
+
3381
+ expect(deltas).toHaveLength(1);
3382
+ expect((deltas[0] as any).delta).toBe(JSON.stringify({ arg: 'initial' }));
3383
+ expect(toolCall.input).toBe(JSON.stringify({ arg: 'replaced' }));
3384
+ });
3385
+
3386
+ it('emits multiple tool-error chunks without duplicate tool-call', async () => {
3387
+ const toolUseId = 'toolu_multi_error';
3388
+ const toolName = 'Read';
3389
+
3390
+ const mockResponse = {
3391
+ async *[Symbol.asyncIterator]() {
3392
+ yield {
3393
+ type: 'assistant',
3394
+ message: {
3395
+ content: [{ type: 'tool_use', id: toolUseId, name: toolName, input: { file: 'x' } }],
3396
+ },
3397
+ };
3398
+ yield {
3399
+ type: 'user',
3400
+ message: {
3401
+ content: [
3402
+ { type: 'tool_error', tool_use_id: toolUseId, name: toolName, error: 'e1' },
3403
+ ],
3404
+ },
3405
+ };
3406
+ yield {
3407
+ type: 'user',
3408
+ message: {
3409
+ content: [
3410
+ { type: 'tool_error', tool_use_id: toolUseId, name: toolName, error: 'e2' },
3411
+ ],
3412
+ },
3413
+ };
3414
+ yield {
3415
+ type: 'result',
3416
+ subtype: 'success',
3417
+ session_id: 'multierror-session',
3418
+ usage: { input_tokens: 1, output_tokens: 0 },
3419
+ };
3420
+ },
3421
+ };
3422
+
3423
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3424
+
3425
+ const { stream } = await model.doStream({
3426
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'run' }] }],
3427
+ } as any);
3428
+
3429
+ const events: ExtendedStreamPart[] = [];
3430
+ const reader = stream.getReader();
3431
+ while (true) {
3432
+ const { done, value } = await reader.read();
3433
+ if (done) break;
3434
+ events.push(value);
3435
+ }
3436
+
3437
+ const toolCalls = events.filter((e) => e.type === 'tool-call');
3438
+ const toolErrors = events.filter((e) => e.type === 'tool-error');
3439
+ expect(toolCalls).toHaveLength(1);
3440
+ expect(toolErrors).toHaveLength(2);
3441
+ });
3442
+
3443
+ it('handles multiple concurrent tool calls', async () => {
3444
+ const id1 = 't1';
3445
+ const id2 = 't2';
3446
+
3447
+ const mockResponse = {
3448
+ async *[Symbol.asyncIterator]() {
3449
+ yield {
3450
+ type: 'assistant',
3451
+ message: {
3452
+ content: [
3453
+ { type: 'tool_use', id: id1, name: 'Read', input: { p: 'a' } },
3454
+ { type: 'tool_use', id: id2, name: 'Bash', input: { c: 'echo' } },
3455
+ ],
3456
+ },
3457
+ };
3458
+ yield {
3459
+ type: 'user',
3460
+ message: {
3461
+ content: [
3462
+ {
3463
+ type: 'tool_result',
3464
+ tool_use_id: id1,
3465
+ name: 'Read',
3466
+ content: 'A',
3467
+ is_error: false,
3468
+ },
3469
+ ],
3470
+ },
3471
+ };
3472
+ yield {
3473
+ type: 'user',
3474
+ message: {
3475
+ content: [
3476
+ {
3477
+ type: 'tool_result',
3478
+ tool_use_id: id2,
3479
+ name: 'Bash',
3480
+ content: 'B',
3481
+ is_error: false,
3482
+ },
3483
+ ],
3484
+ },
3485
+ };
3486
+ yield {
3487
+ type: 'result',
3488
+ subtype: 'success',
3489
+ session_id: 'concurrent',
3490
+ usage: { input_tokens: 1, output_tokens: 1 },
3491
+ };
3492
+ },
3493
+ };
3494
+
3495
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3496
+
3497
+ const { stream } = await model.doStream({
3498
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'run' }] }],
3499
+ } as any);
3500
+ const events: ExtendedStreamPart[] = [];
3501
+ const reader = stream.getReader();
3502
+ while (true) {
3503
+ const { done, value } = await reader.read();
3504
+ if (done) break;
3505
+ events.push(value);
3506
+ }
3507
+
3508
+ const toolCalls = events.filter((e) => e.type === 'tool-call');
3509
+ const toolResults = events.filter((e) => e.type === 'tool-result');
3510
+ expect(toolCalls).toHaveLength(2);
3511
+ expect(toolResults).toHaveLength(2);
3512
+ });
3513
+
3514
+ it('supports interleaved text and tool events', async () => {
3515
+ const toolUseId = 'tool_interleave';
3516
+ const mockResponse = {
3517
+ async *[Symbol.asyncIterator]() {
3518
+ yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Intro ' }] } };
3519
+ yield {
3520
+ type: 'assistant',
3521
+ message: {
3522
+ content: [{ type: 'tool_use', id: toolUseId, name: 'Read', input: { p: '/f' } }],
3523
+ },
3524
+ };
3525
+ yield {
3526
+ type: 'user',
3527
+ message: {
3528
+ content: [
3529
+ {
3530
+ type: 'tool_result',
3531
+ tool_use_id: toolUseId,
3532
+ name: 'Read',
3533
+ content: 'OK',
3534
+ is_error: false,
3535
+ },
3536
+ ],
3537
+ },
3538
+ };
3539
+ yield { type: 'assistant', message: { content: [{ type: 'text', text: ' Outro' }] } };
3540
+ yield {
3541
+ type: 'result',
3542
+ subtype: 'success',
3543
+ session_id: 'inter',
3544
+ usage: { input_tokens: 1, output_tokens: 1 },
3545
+ };
3546
+ },
3547
+ };
3548
+
3549
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3550
+ const { stream } = await model.doStream({
3551
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
3552
+ } as any);
3553
+ const events: ExtendedStreamPart[] = [];
3554
+ const reader = stream.getReader();
3555
+ while (true) {
3556
+ const { done, value } = await reader.read();
3557
+ if (done) break;
3558
+ events.push(value);
3559
+ }
3560
+
3561
+ const firstTextIndex = events.findIndex((e) => e.type === 'text-delta');
3562
+ const toolCallIndex = events.findIndex((e) => e.type === 'tool-call');
3563
+ const lastTextIndex = events.findIndex(
3564
+ (e, i) => i > toolCallIndex && e.type === 'text-delta'
3565
+ );
3566
+ expect(firstTextIndex).toBeGreaterThan(-1);
3567
+ expect(toolCallIndex).toBeGreaterThan(firstTextIndex);
3568
+ expect(lastTextIndex).toBeGreaterThan(toolCallIndex);
3569
+ });
3570
+
3571
+ it('passes outputFormat to SDK when responseFormat has schema', async () => {
3572
+ // SDK 0.1.45+ uses native structured outputs via outputFormat
3573
+ const mockResponse = {
3574
+ async *[Symbol.asyncIterator]() {
3575
+ yield {
3576
+ type: 'result',
3577
+ subtype: 'success',
3578
+ session_id: 'structured-output-session',
3579
+ structured_output: { name: 'test', value: 42 },
3580
+ usage: { input_tokens: 10, output_tokens: 20 },
3581
+ total_cost_usd: 0.002,
3582
+ duration_ms: 100,
3583
+ };
3584
+ },
3585
+ };
3586
+
3587
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3588
+
3589
+ const schema = {
3590
+ type: 'object',
3591
+ properties: {
3592
+ name: { type: 'string' },
3593
+ value: { type: 'number' },
3594
+ },
3595
+ required: ['name', 'value'],
3596
+ };
3597
+
3598
+ const { stream } = await model.doStream({
3599
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Generate JSON' }] }],
3600
+ responseFormat: { type: 'json', schema },
3601
+ } as any);
3602
+
3603
+ const events: ExtendedStreamPart[] = [];
3604
+ const reader = stream.getReader();
3605
+ while (true) {
3606
+ const { done, value } = await reader.read();
3607
+ if (done) break;
3608
+ events.push(value);
3609
+ }
3610
+
3611
+ // Verify outputFormat was passed correctly to SDK
3612
+ const call = vi.mocked(mockQuery).mock.calls[0]?.[0] as {
3613
+ options: { outputFormat?: { type: string; schema: unknown } };
3614
+ };
3615
+ expect(call.options?.outputFormat).toEqual({
3616
+ type: 'json_schema',
3617
+ schema,
3618
+ });
3619
+
3620
+ // Verify structured output was emitted as text
3621
+ const textDelta = events.find((e) => e.type === 'text-delta') as any;
3622
+ expect(textDelta).toBeDefined();
3623
+ expect(textDelta.delta).toBe('{"name":"test","value":42}');
3624
+
3625
+ const finishEvent = events.find((e) => e.type === 'finish') as any;
3626
+ expect(finishEvent).toBeDefined();
3627
+ expect(finishEvent.providerMetadata?.['claude-code']?.sessionId).toBe(
3628
+ 'structured-output-session'
3629
+ );
3630
+ });
3631
+
3632
+ it('uses consistent fallback name for unknown tools', async () => {
3633
+ const toolUseId = 'toolu_unknown_name';
3634
+
3635
+ const mockResponse = {
3636
+ async *[Symbol.asyncIterator]() {
3637
+ yield {
3638
+ type: 'assistant',
3639
+ message: {
3640
+ content: [
3641
+ {
3642
+ type: 'tool_use',
3643
+ id: toolUseId,
3644
+ // name omitted/unknown
3645
+ input: { x: 1 },
3646
+ } as any,
3647
+ ],
3648
+ },
3649
+ };
3650
+ yield {
3651
+ type: 'user',
3652
+ message: {
3653
+ content: [
3654
+ {
3655
+ type: 'tool_result',
3656
+ tool_use_id: toolUseId,
3657
+ content: 'ok',
3658
+ },
3659
+ ],
3660
+ },
3661
+ };
3662
+ yield {
3663
+ type: 'result',
3664
+ subtype: 'success',
3665
+ session_id: 's-unknown',
3666
+ usage: { input_tokens: 1, output_tokens: 1 },
3667
+ };
3668
+ },
3669
+ };
3670
+
3671
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3672
+
3673
+ const { stream } = await model.doStream({
3674
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'run' }] }],
3675
+ } as any);
3676
+
3677
+ const events: ExtendedStreamPart[] = [];
3678
+ const reader = stream.getReader();
3679
+ while (true) {
3680
+ const { done, value } = await reader.read();
3681
+ if (done) break;
3682
+ events.push(value);
3683
+ }
3684
+
3685
+ const toolCall = events.find((e) => e.type === 'tool-call');
3686
+ expect(toolCall).toMatchObject({
3687
+ type: 'tool-call',
3688
+ toolName: 'unknown-tool',
3689
+ });
3690
+ });
3691
+
3692
+ it('emits finish event with truncated metadata when CLI truncates JSON stream', async () => {
3693
+ const repeatedItems = Array.from({ length: 300 }, (_, i) => `item-${i}`).join('","');
3694
+ const partialJson = `{"result": {"items": ["${repeatedItems}`;
3695
+ const truncationPosition = partialJson.length;
3696
+ const truncationError = new SyntaxError(
3697
+ `Unterminated string in JSON at position ${truncationPosition}`
3698
+ );
3699
+
3700
+ const mockResponse = {
3701
+ async *[Symbol.asyncIterator]() {
3702
+ yield {
3703
+ type: 'assistant',
3704
+ message: {
3705
+ content: [{ type: 'text', text: partialJson }],
3706
+ },
3707
+ };
3708
+ throw truncationError;
3709
+ },
3710
+ };
3711
+
3712
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3713
+
3714
+ const { stream } = await model.doStream({
3715
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Return JSON' }] }],
3716
+ responseFormat: { type: 'json' },
3717
+ } as any);
3718
+
3719
+ const events: LanguageModelV3StreamPart[] = [];
3720
+ const reader = stream.getReader();
3721
+ while (true) {
3722
+ const { done, value } = await reader.read();
3723
+ if (done) break;
3724
+ events.push(value);
3725
+ }
3726
+
3727
+ expect(events.some((event) => event.type === 'error')).toBe(false);
3728
+
3729
+ const finishEvent = events.find((event) => event.type === 'finish');
3730
+ expect(finishEvent).toBeDefined();
3731
+ expect(
3732
+ finishEvent && 'finishReason' in finishEvent
3733
+ ? (finishEvent.finishReason as { unified: string }).unified
3734
+ : undefined
3735
+ ).toBe('length');
3736
+
3737
+ const finishMetadata =
3738
+ finishEvent && 'providerMetadata' in finishEvent ? finishEvent.providerMetadata : undefined;
3739
+ const claudeMetadata =
3740
+ finishMetadata && 'claude-code' in finishMetadata
3741
+ ? (finishMetadata['claude-code'] as Record<string, unknown>)
3742
+ : undefined;
3743
+ expect(claudeMetadata?.truncated).toBe(true);
3744
+
3745
+ const serializedWarnings = Array.isArray(claudeMetadata?.warnings)
3746
+ ? (claudeMetadata?.warnings as Array<Record<string, unknown>>)
3747
+ : [];
3748
+ expect(
3749
+ serializedWarnings.some(
3750
+ (warning) =>
3751
+ typeof warning.message === 'string' &&
3752
+ warning.message.includes('output ended unexpectedly')
3753
+ )
3754
+ ).toBe(true);
3755
+
3756
+ const textDelta = events.find((event) => event.type === 'text-delta');
3757
+ expect(textDelta && 'delta' in textDelta ? textDelta.delta : '').toContain('items');
3758
+
3759
+ const textEnd = events.find((event) => event.type === 'text-end');
3760
+ expect(textEnd).toBeDefined();
3761
+ });
3762
+
3763
+ it('emits an error event for malformed JSON without treating it as truncation', async () => {
3764
+ const partialJson = '{"result": {"items": [1, 2}}';
3765
+ const parseError = new SyntaxError('Unexpected token } in JSON at position 24');
3766
+
3767
+ const mockResponse = {
3768
+ async *[Symbol.asyncIterator]() {
3769
+ yield {
3770
+ type: 'assistant',
3771
+ message: {
3772
+ content: [{ type: 'text', text: partialJson }],
3773
+ },
3774
+ };
3775
+ throw parseError;
3776
+ },
3777
+ };
3778
+
3779
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3780
+
3781
+ const { stream } = await model.doStream({
3782
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Return JSON' }] }],
3783
+ responseFormat: { type: 'json' },
3784
+ } as any);
3785
+
3786
+ const events: ExtendedStreamPart[] = [];
3787
+ const reader = stream.getReader();
3788
+ while (true) {
3789
+ const { done, value } = await reader.read();
3790
+ if (done) break;
3791
+ events.push(value);
3792
+ }
3793
+
3794
+ const errorEvent = events.find((event) => event.type === 'error');
3795
+ expect(errorEvent).toBeDefined();
3796
+ expect(
3797
+ errorEvent && 'error' in errorEvent && errorEvent.error
3798
+ ? (errorEvent.error as Error).message
3799
+ : ''
3800
+ ).toMatch(/Unexpected token \}/);
3801
+ expect(events.some((event) => event.type === 'finish')).toBe(false);
3802
+ });
3803
+
3804
+ it('should include modelUsage in providerMetadata when available', async () => {
3805
+ const mockModelUsage = {
3806
+ 'claude-sonnet-4-20250514': {
3807
+ inputTokens: 100,
3808
+ outputTokens: 50,
3809
+ cacheReadInputTokens: 20,
3810
+ cacheCreationInputTokens: 10,
3811
+ webSearchRequests: 0,
3812
+ costUSD: 0.001,
3813
+ contextWindow: 200000,
3814
+ maxOutputTokens: 16384,
3815
+ },
3816
+ };
3817
+
3818
+ const mockResponse = {
3819
+ async *[Symbol.asyncIterator]() {
3820
+ yield {
3821
+ type: 'result',
3822
+ subtype: 'success',
3823
+ session_id: 'model-usage-session',
3824
+ usage: { input_tokens: 100, output_tokens: 50 },
3825
+ total_cost_usd: 0.001,
3826
+ duration_ms: 500,
3827
+ modelUsage: mockModelUsage,
3828
+ };
3829
+ },
3830
+ };
3831
+
3832
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3833
+
3834
+ const result = await model.doStream({
3835
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
3836
+ });
3837
+
3838
+ const chunks: any[] = [];
3839
+ const reader = result.stream.getReader();
3840
+ while (true) {
3841
+ const { done, value } = await reader.read();
3842
+ if (done) break;
3843
+ chunks.push(value);
3844
+ }
3845
+
3846
+ const finishChunk = chunks.find((c) => c.type === 'finish');
3847
+ expect(finishChunk.providerMetadata['claude-code'].modelUsage).toEqual(mockModelUsage);
3848
+ });
3849
+
3850
+ it('should not include modelUsage in doStream providerMetadata when not available', async () => {
3851
+ const mockResponse = {
3852
+ async *[Symbol.asyncIterator]() {
3853
+ yield {
3854
+ type: 'result',
3855
+ subtype: 'success',
3856
+ session_id: 'no-model-usage-session',
3857
+ usage: { input_tokens: 100, output_tokens: 50 },
3858
+ total_cost_usd: 0.001,
3859
+ duration_ms: 500,
3860
+ // No modelUsage field
3861
+ };
3862
+ },
3863
+ };
3864
+
3865
+ vi.mocked(mockQuery).mockReturnValue(mockResponse as any);
3866
+
3867
+ const result = await model.doStream({
3868
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Test' }] }],
3869
+ });
3870
+
3871
+ const chunks: any[] = [];
3872
+ const reader = result.stream.getReader();
3873
+ while (true) {
3874
+ const { done, value } = await reader.read();
3875
+ if (done) break;
3876
+ chunks.push(value);
3877
+ }
3878
+
3879
+ const finishChunk = chunks.find((c) => c.type === 'finish');
3880
+ expect(finishChunk.providerMetadata['claude-code'].modelUsage).toBeUndefined();
3881
+ });
3882
+ });
3883
+ });