@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.
- package/README.md +90 -0
- package/dist/index.cjs +2522 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +848 -0
- package/dist/index.d.ts +848 -0
- package/dist/index.js +2484 -0
- package/dist/index.js.map +1 -0
- package/docs/content/README.md +337 -0
- package/docs/content/agent-teams.mdx +324 -0
- package/docs/content/api.mdx +757 -0
- package/docs/content/best-practices.mdx +624 -0
- package/docs/content/examples.mdx +675 -0
- package/docs/content/guide.mdx +516 -0
- package/docs/content/index.mdx +99 -0
- package/docs/content/installation.mdx +246 -0
- package/docs/content/skills.mdx +548 -0
- package/docs/content/troubleshooting.mdx +588 -0
- package/docs/examples/README.md +499 -0
- package/docs/examples/abort-signal.ts +125 -0
- package/docs/examples/agent-teams.ts +122 -0
- package/docs/examples/basic-usage.ts +73 -0
- package/docs/examples/check-cli.ts +51 -0
- package/docs/examples/conversation-history.ts +69 -0
- package/docs/examples/custom-config.ts +90 -0
- package/docs/examples/generate-object-constraints.ts +209 -0
- package/docs/examples/generate-object.ts +211 -0
- package/docs/examples/hooks-callbacks.ts +63 -0
- package/docs/examples/images.ts +76 -0
- package/docs/examples/integration-test.ts +241 -0
- package/docs/examples/limitations.ts +150 -0
- package/docs/examples/logging-custom-logger.ts +99 -0
- package/docs/examples/logging-default.ts +55 -0
- package/docs/examples/logging-disabled.ts +74 -0
- package/docs/examples/logging-verbose.ts +64 -0
- package/docs/examples/long-running-tasks.ts +179 -0
- package/docs/examples/message-injection.ts +210 -0
- package/docs/examples/mid-stream-injection.ts +126 -0
- package/docs/examples/run-all-examples.sh +48 -0
- package/docs/examples/sdk-tools-callbacks.ts +49 -0
- package/docs/examples/skills-discovery.ts +144 -0
- package/docs/examples/skills-management.ts +140 -0
- package/docs/examples/stream-object.ts +80 -0
- package/docs/examples/streaming.ts +52 -0
- package/docs/examples/structured-output-repro.ts +227 -0
- package/docs/examples/tool-management.ts +215 -0
- package/docs/examples/tool-streaming.ts +132 -0
- package/docs/examples/zod4-compatibility-test.ts +290 -0
- package/docs/src/claude-code-language-model.test.ts +3883 -0
- package/docs/src/claude-code-language-model.ts +2586 -0
- package/docs/src/claude-code-provider.test.ts +97 -0
- package/docs/src/claude-code-provider.ts +179 -0
- package/docs/src/convert-to-claude-code-messages.images.test.ts +104 -0
- package/docs/src/convert-to-claude-code-messages.test.ts +193 -0
- package/docs/src/convert-to-claude-code-messages.ts +419 -0
- package/docs/src/errors.test.ts +213 -0
- package/docs/src/errors.ts +216 -0
- package/docs/src/index.test.ts +49 -0
- package/docs/src/index.ts +98 -0
- package/docs/src/logger.integration.test.ts +164 -0
- package/docs/src/logger.test.ts +184 -0
- package/docs/src/logger.ts +65 -0
- package/docs/src/map-claude-code-finish-reason.test.ts +120 -0
- package/docs/src/map-claude-code-finish-reason.ts +60 -0
- package/docs/src/mcp-helpers.test.ts +71 -0
- package/docs/src/mcp-helpers.ts +123 -0
- package/docs/src/message-injection.test.ts +460 -0
- package/docs/src/types.ts +447 -0
- package/docs/src/validation.test.ts +558 -0
- package/docs/src/validation.ts +360 -0
- 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
|
+
});
|