ai-test-kit 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 ADDED
@@ -0,0 +1,924 @@
1
+ # ai-test-kit
2
+
3
+ <p align="center">Test utilities for the AI SDK: mock models, content and stream-part builders, fully type-safe</p>
4
+ <p align="center">
5
+ <a href="https://www.npmjs.com/package/ai-test-kit" alt="ai-test-kit"><img src="https://img.shields.io/npm/dt/ai-test-kit?label=ai-test-kit"></a> <a href="https://github.com/zirkelc/ai-test-kit/actions/workflows/ci.yml" alt="CI"><img src="https://img.shields.io/github/actions/workflow/status/zirkelc/ai-test-kit/ci.yml?branch=main"></a>
6
+ </p>
7
+
8
+ This library provides ergonomic, type-safe helpers for testing code built on the AI SDK: a fluent API to mock [`generateText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text) / [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text), build content and stream parts, and assert on results. It implements its own `LanguageModelV3` mock and builds on the AI SDK's own stream helpers (`simulateReadableStream` and the stream converters).
9
+
10
+ ### Why?
11
+
12
+ The AI SDK ships `MockLanguageModelV3` and other helpers under `ai/test`, but they are deliberately low-level. In practice every project ends up rebuilding the same helpers to:
13
+
14
+ - **Mock a model**: return text, throw an error, or replay a scripted response per call
15
+ - **Build content and stream parts**: assemble valid `text-start` → `text-delta` → `text-end` → `finish` streams by hand
16
+ - **Keep tests deterministic**: pin message ids and timestamps so snapshots are stable
17
+
18
+ This library provides those helpers as small, composable builders. Models are `vi.fn()` spies, so you can assert on calls with the full Vitest API while also reading the recorded call arguments directly.
19
+
20
+ Helpers are split by layer, each under its own entry point so an import only pulls in the types it needs:
21
+
22
+ - `ai-test-kit/language` — the model layer: mock a `LanguageModelV3`, build `LanguageModelV3Content` and stream parts (and later `ai-test-kit/embedding`, `ai-test-kit/image`)
23
+ - `ai-test-kit/ui` — the UI layer: build `UIMessagePart`, `UIMessageChunk`, and `UIMessage` fixtures, optionally typed to your own `UIMessage`
24
+
25
+ ### Installation
26
+
27
+ ```bash
28
+ npm install -D ai-test-kit
29
+ ```
30
+
31
+ `ai` and `vitest` are peer dependencies.
32
+
33
+ ## Usage
34
+
35
+ ### Language Models
36
+
37
+ Helpers from `ai-test-kit/language` to mock a model and build the content and stream parts it returns.
38
+
39
+ #### Creating a Mock Model
40
+
41
+ Pass a response to `MockLanguageModel.from()`. A `string` is the common case: it serves both `doGenerate` and `doStream`.
42
+
43
+ ```typescript
44
+ import { generateText } from 'ai';
45
+ import { MockLanguageModel } from 'ai-test-kit/language';
46
+
47
+ const model = MockLanguageModel.from('Hello, world!');
48
+
49
+ const result = await generateText({ model, prompt: 'Hi' });
50
+ result.text; // 'Hello, world!'
51
+ ```
52
+
53
+ #### Generate and Stream
54
+
55
+ The same model answers both `generateText()` and `streamText()`. For streaming, a string is assembled into a `stream-start` → `text-start` → `text-delta*` → `text-end` → `finish` sequence.
56
+
57
+ ```typescript
58
+ import { streamText } from 'ai';
59
+ import { MockLanguageModel, Stream } from 'ai-test-kit/language';
60
+
61
+ const model = MockLanguageModel.from('Hello World');
62
+
63
+ const result = streamText({ model, prompt: 'Hi' });
64
+ const text = (await Stream.toArray(result.textStream)).join(''); // 'Hello World'
65
+ ```
66
+
67
+ #### Throwing Errors
68
+
69
+ Pass an `Error` to make the model throw, for testing error handling and retries.
70
+
71
+ ```typescript
72
+ const model = MockLanguageModel.from(new Error('rate limited'));
73
+
74
+ await expect(generateText({ model, prompt: 'Hi' })).rejects.toThrow();
75
+ ```
76
+
77
+ #### Sequenced Responses
78
+
79
+ Pass an array to script a response per call. The model advances through the array and clamps to the last entry once exhausted, ideal for retry and fallback tests. An array models the sequence directly and is easy to build programmatically.
80
+
81
+ ```typescript
82
+ // fail, fail, then succeed
83
+ const model = MockLanguageModel.from([new Error('429'), new Error('429'), 'recovered']);
84
+
85
+ await generateText({ model, prompt: 'Hi' }).catch(() => {});
86
+ await generateText({ model, prompt: 'Hi' }).catch(() => {});
87
+ const result = await generateText({ model, prompt: 'Hi' });
88
+ result.text; // 'recovered'
89
+ ```
90
+
91
+ #### Building Content
92
+
93
+ Use `Content` to assemble the parts a model returns from `doGenerate`. Pass them via the `content` response form. Like a plain `string`, a `{ content }` mock also serves `streamText()` — the stream is derived from the parts.
94
+
95
+ ```typescript
96
+ import { Content, MockLanguageModel } from 'ai-test-kit/language';
97
+
98
+ const model = MockLanguageModel.from({
99
+ content: [
100
+ Content.text('Here is the weather:'),
101
+ Content.toolCall({ toolCallId: 'call-1', toolName: 'weather', input: { city: 'Tokyo' } }),
102
+ ],
103
+ });
104
+
105
+ const result = await generateText({ model, prompt: 'Weather in Tokyo?' });
106
+ result.toolCalls[0].toolName; // 'weather'
107
+ ```
108
+
109
+ #### Usage and Finish Reason
110
+
111
+ By default a mock reports a `stop` finish reason and a small fixed token usage. Override them via the `{ content, finishReason, usage }` form to test code that branches on the finish reason or tracks token usage.
112
+
113
+ ```typescript
114
+ import { Content, MockLanguageModel } from 'ai-test-kit/language';
115
+
116
+ const model = MockLanguageModel.from({
117
+ content: [Content.text('truncated…')],
118
+ finishReason: MockLanguageModel.finishReason('length'),
119
+ usage: MockLanguageModel.usage({ outputTokens: { total: 50 } }),
120
+ });
121
+
122
+ const result = await generateText({ model, prompt: 'Hi' });
123
+ result.finishReason; // 'length'
124
+ result.usage; // the configured token usage
125
+ ```
126
+
127
+ #### Building Streams
128
+
129
+ Use `StreamParts` to compose a stream from atoms. The text-like builders return a `start` / `delta` / `end` block (no trailing `finish`), so streams compose by concatenation.
130
+
131
+ ```typescript
132
+ import { MockLanguageModel, StreamParts } from 'ai-test-kit/language';
133
+
134
+ const model = MockLanguageModel.from({
135
+ stream: [
136
+ StreamParts.streamStart(),
137
+ ...StreamParts.text('Hello', { length: 1 }), // emit one character per delta
138
+ ...StreamParts.toolInput({ id: 't1', toolName: 'weather', input: { city: 'Tokyo' } }),
139
+ StreamParts.toolCall({ toolCallId: 'call-1', toolName: 'weather', input: { city: 'Tokyo' } }),
140
+ StreamParts.finish(),
141
+ ],
142
+ });
143
+ ```
144
+
145
+ For timing tests, give the `stream` form a `{ chunks, ... }` object with delays (or use `Stream.simulate`):
146
+
147
+ ```typescript
148
+ const model = MockLanguageModel.from({
149
+ stream: {
150
+ chunks: [...StreamParts.text('slow'), StreamParts.finish()],
151
+ initialDelayInMs: 10,
152
+ chunkDelayInMs: 5,
153
+ },
154
+ });
155
+ ```
156
+
157
+ #### Different Responses per Method
158
+
159
+ Use the `{ generate, stream }` form to drive `doGenerate` and `doStream` independently — for example to return plain text non-streaming but a richer sequence when streamed.
160
+
161
+ ```typescript
162
+ import { MockLanguageModel, StreamParts } from 'ai-test-kit/language';
163
+
164
+ const model = MockLanguageModel.from({
165
+ generate: 'Final answer',
166
+ stream: [...StreamParts.text('Final answer'), StreamParts.finish()],
167
+ });
168
+ ```
169
+
170
+ #### Inspecting Streams
171
+
172
+ Use `Stream` to build, drain, and read stream parts when asserting.
173
+
174
+ ```typescript
175
+ import { Stream, StreamParts } from 'ai-test-kit/language';
176
+
177
+ const parts = [...StreamParts.text('Hello World'), StreamParts.finish()];
178
+
179
+ Stream.text(parts); // 'Hello World'
180
+ Stream.finishReason(parts)?.unified; // 'stop'
181
+
182
+ const drained = await Stream.toArray(Stream.from(parts)); // round-trips parts
183
+ ```
184
+
185
+ #### Deterministic Output
186
+
187
+ `generateText()` / `streamText()` assign a random `response.id` and a wall-clock `response.timestamp`. When a test asserts on those (e.g. snapshots), spread `Options` to pin them. It is not needed for ordinary assertions like `result.text`.
188
+
189
+ ```typescript
190
+ import { MockLanguageModel, Options } from 'ai-test-kit/language';
191
+
192
+ const model = MockLanguageModel.from('Hi');
193
+
194
+ await generateText({ model, prompt: 'x', ...Options.generate });
195
+ streamText({ model, prompt: 'x', ...Options.stream });
196
+ ```
197
+
198
+ #### Inspecting Calls
199
+
200
+ `doGenerate` and `doStream` are `vi.fn()` spies, so the full Vitest API works. Each call is also recorded on `doGenerateCalls` / `doStreamCalls`, which you can read without Vitest.
201
+
202
+ ```typescript
203
+ const model = MockLanguageModel.from('hi');
204
+
205
+ await generateText({ model, prompt: 'question' });
206
+
207
+ // Vitest spy
208
+ expect(model.doGenerate).toHaveBeenCalledTimes(1);
209
+ model.doGenerate.mock.calls[0][0].prompt;
210
+
211
+ // Recorded call options
212
+ model.doGenerateCalls.length; // 1
213
+ model.doGenerateCalls[0].prompt;
214
+ ```
215
+
216
+ #### Custom Identity
217
+
218
+ Override `provider` and `modelId`; otherwise the model uses `mock-provider` and an auto-incrementing id.
219
+
220
+ ```typescript
221
+ const model = MockLanguageModel.from('hi', { provider: 'acme', modelId: 'acme-1' });
222
+ model.provider; // 'acme'
223
+ model.modelId; // 'acme-1'
224
+ ```
225
+
226
+ ### UI Messages
227
+
228
+ Helpers from `ai-test-kit/ui` to build the messages, parts, and chunks exchanged between the server and the client. Use them to test code that operates on `UIMessage`, `UIMessageChunk`, or `UIMessagePart` (custom transports, stream consumers, message reducers). The builders return plain objects shaped to the AI SDK's UI types, so the entry has no runtime cost beyond the types.
229
+
230
+ #### Building Parts, Chunks, and Messages
231
+
232
+ `UIParts` builds message parts, `UIChunks` builds stream chunks, and `UIMessages` builds whole messages from a `string` shortcut or an array of parts.
233
+
234
+ ```typescript
235
+ import { UIChunks, UIMessages, UIParts } from 'ai-test-kit/ui';
236
+
237
+ const message = UIMessages.assistant([
238
+ UIParts.text('Here is the weather:'),
239
+ UIParts.sourceUrl({ sourceId: 's1', url: 'https://example.com' }),
240
+ ]);
241
+
242
+ const chunks = [
243
+ UIChunks.start(),
244
+ ...UIChunks.text('Hello', { length: 1 }), // text-start → text-delta* → text-end
245
+ UIChunks.finish(),
246
+ ];
247
+ ```
248
+
249
+ The text-like builders (`UIChunks.text` / `UIChunks.reasoning` / `UIChunks.toolInput`) return a block of atoms, so streams compose by concatenation. The individual atoms (`UIChunks.textStart`, `textDelta`, …) are also available.
250
+
251
+ #### Consuming a Chunk Stream
252
+
253
+ The chunk builders shine when testing code that consumes a `UIMessageChunk` stream. `Stream` (from `ai-test-kit/language`) is layer-agnostic, so it builds and drains chunk streams too.
254
+
255
+ ```typescript
256
+ import { Stream } from 'ai-test-kit/language';
257
+ import { UIChunks } from 'ai-test-kit/ui';
258
+
259
+ const chunks = [UIChunks.start(), ...UIChunks.text('Hello'), UIChunks.finish()];
260
+
261
+ const stream = Stream.from(chunks); // ReadableStream<UIMessageChunk>
262
+
263
+ // pass `stream` to the code under test (a transport, reducer, readUIMessageStream, …),
264
+ // or drain it to assert on the chunks directly
265
+ const received = await Stream.toArray(stream);
266
+ received.length; // 5
267
+ ```
268
+
269
+ #### Typing to Your Own `UIMessage`
270
+
271
+ By default `data` payloads, tool names, and message metadata are loose. Pass your own `UIMessage` type to `fromUIMessage()` once and the bound builders infer them.
272
+
273
+ ```typescript
274
+ import type { UIMessage } from 'ai';
275
+ import { fromUIMessage } from 'ai-test-kit/ui';
276
+
277
+ type MyUIMessage = UIMessage<
278
+ { traceId: string }, // metadata
279
+ { weather: { city: string } }, // data parts
280
+ { search: { input: { q: string }; output: { hits: number } } } // tools
281
+ >;
282
+
283
+ const { UIParts, UIChunks, UIMessages } = fromUIMessage<MyUIMessage>();
284
+
285
+ UIChunks.data('weather', { city: 'Tokyo' }); // name + payload typed
286
+ UIChunks.messageMetadata({ traceId: 't1' }); // metadata typed
287
+ UIParts.tool('search', { toolCallId: 'c1', state: 'output-available', input: { q: 'cats' }, output: { hits: 3 } });
288
+ ```
289
+
290
+ ## API
291
+
292
+ ### Language Models
293
+
294
+ Builders and the mock model from `ai-test-kit/language`.
295
+
296
+ #### `MockLanguageModel`
297
+
298
+ Namespace for the mock model and its result builders. The model returned by `.from()` exposes `doGenerate` / `doStream` as `vi.fn()` spies and records call options on `doGenerateCalls` / `doStreamCalls`. The namespace and the instance type share the name.
299
+
300
+ #### `.from(input?, options?)`
301
+
302
+ Creates a mock `LanguageModelV3` from a response spec (or a sequence of them).
303
+
304
+ ```ts
305
+ MockLanguageModel.from(input?: MockResponse | MockResponse[], options?: MockLanguageModelOptions): MockLanguageModel
306
+ // MockLanguageModel.from('Hi'): a model returning 'Hi' from generate and stream
307
+ // MockLanguageModel.from(new Error('429')): a model that throws from generate and stream
308
+ // MockLanguageModel.from({ content: [Content.text('Hi')] }): a model returning those parts (stream derived from them)
309
+ // MockLanguageModel.from({ generate: 'A', stream: [...] }): drives doGenerate and doStream independently
310
+ // MockLanguageModel.from([new Error('429'), 'ok']): sequences responses per call, clamping to the last
311
+ ```
312
+
313
+ - `input` defaults to a single response repeated for every call; an array sequences one response per call, clamped to the last. See [`MockResponse`](#mockresponse).
314
+ - `options.provider` defaults to `mock-provider`; `options.modelId` defaults to an auto-incrementing `mock-model-{n}`.
315
+
316
+ #### `.content(input)`
317
+
318
+ ```ts
319
+ MockLanguageModel.content(input: string | LanguageModelV3Content[]): LanguageModelV3Content[]
320
+ // MockLanguageModel.content('hi'): [{ type: 'text', text: 'hi' }]
321
+ // MockLanguageModel.content([Content.text('hi')]): [{ type: 'text', text: 'hi' }] — array passes through
322
+ ```
323
+
324
+ #### `.generateResult(input)`
325
+
326
+ ```ts
327
+ MockLanguageModel.generateResult(input: string | { content: LanguageModelV3Content[]; finishReason?: LanguageModelV3FinishReason; usage?: LanguageModelV3Usage }): LanguageModelV3GenerateResult
328
+ // MockLanguageModel.generateResult('hi'): { content: [{ type: 'text', text: 'hi' }], finishReason: { unified: 'stop', raw: 'stop' }, usage, warnings: [] }
329
+ // MockLanguageModel.generateResult({ content: [Content.text('hi')] }): { content: [{ type: 'text', text: 'hi' }], finishReason: { unified: 'stop', raw: 'stop' }, usage, warnings: [] }
330
+ ```
331
+
332
+ #### `.streamResult(input, options?)`
333
+
334
+ ```ts
335
+ MockLanguageModel.streamResult(input: string | LanguageModelV3StreamPart[], options?: StreamDelayOptions): LanguageModelV3StreamResult
336
+ // MockLanguageModel.streamResult('hi'): { stream } — a ReadableStream of stream-start → text → finish
337
+ // MockLanguageModel.streamResult([...StreamParts.text('hi'), StreamParts.finish()]): { stream } — a ReadableStream of the given parts
338
+ ```
339
+
340
+ #### `.usage(overrides?)`
341
+
342
+ ```ts
343
+ MockLanguageModel.usage(overrides?: { inputTokens?: Partial<LanguageModelV3Usage['inputTokens']>; outputTokens?: Partial<LanguageModelV3Usage['outputTokens']> }): LanguageModelV3Usage
344
+ // MockLanguageModel.usage({ outputTokens: { total: 99 } }): { inputTokens: { total: 10, … }, outputTokens: { total: 99, … } }
345
+ ```
346
+
347
+ #### `.finishReason(unified?)`
348
+
349
+ ```ts
350
+ MockLanguageModel.finishReason(unified?: LanguageModelV3FinishReason['unified']): LanguageModelV3FinishReason
351
+ // MockLanguageModel.finishReason('length'): { unified: 'length', raw: 'length' }
352
+ ```
353
+
354
+ #### `Content`
355
+
356
+ Builders for the static content parts returned from `doGenerate`.
357
+
358
+ #### `.text(text)`
359
+
360
+ ```ts
361
+ Content.text(text: string): LanguageModelV3Text
362
+ // Content.text('Hi'): { type: 'text', text: 'Hi' }
363
+ ```
364
+
365
+ #### `.reasoning(text)`
366
+
367
+ ```ts
368
+ Content.reasoning(text: string): LanguageModelV3Reasoning
369
+ // Content.reasoning('Because...'): { type: 'reasoning', text: 'Because...' }
370
+ ```
371
+
372
+ #### `.toolCall(args)`
373
+
374
+ ```ts
375
+ Content.toolCall(args: { toolCallId: string; toolName: string; input: unknown }): LanguageModelV3ToolCall
376
+ // Content.toolCall({ toolCallId: 'c1', toolName: 'weather', input: { city: 'Tokyo' } }): { type: 'tool-call', toolCallId: 'c1', toolName: 'weather', input: '{"city":"Tokyo"}' } — input is JSON-stringified unless already a string
377
+ ```
378
+
379
+ #### `.toolResult(args)`
380
+
381
+ ```ts
382
+ Content.toolResult(args: { toolCallId: string; toolName: string; result: unknown; isError?: boolean }): LanguageModelV3ToolResult
383
+ // Content.toolResult({ toolCallId: 'c1', toolName: 'weather', result: { temp: 20 } }): { type: 'tool-result', toolCallId: 'c1', toolName: 'weather', result: { temp: 20 } }
384
+ ```
385
+
386
+ #### `.file(args)`
387
+
388
+ ```ts
389
+ Content.file(args: { mediaType: string; data: string | Uint8Array }): LanguageModelV3File
390
+ // Content.file({ mediaType: 'image/png', data: 'abc' }): { type: 'file', mediaType: 'image/png', data: 'abc' }
391
+ ```
392
+
393
+ #### `.source(args)`
394
+
395
+ ```ts
396
+ Content.source(args: { id: string; url: string; title?: string }): LanguageModelV3Source
397
+ // Content.source({ id: 's1', url: 'https://example.com' }): { type: 'source', sourceType: 'url', id: 's1', url: 'https://example.com' }
398
+ ```
399
+
400
+ #### `StreamParts`
401
+
402
+ Builders for individual stream parts emitted by `doStream`. The text-like builders return a `start` / `delta` / `end` block (without `finish`); control parts are single parts.
403
+
404
+ #### `.text(text, options?)`
405
+
406
+ ```ts
407
+ StreamParts.text(text: string, options?: StreamPartOptions): LanguageModelV3StreamPart[]
408
+ // StreamParts.text('Hi'): [{ type: 'text-start', id: '1' }, { type: 'text-delta', id: '1', delta: 'Hi' }, { type: 'text-end', id: '1' }]
409
+ ```
410
+
411
+ #### `.reasoning(text, options?)`
412
+
413
+ ```ts
414
+ StreamParts.reasoning(text: string, options?: StreamPartOptions): LanguageModelV3StreamPart[]
415
+ // StreamParts.reasoning('Hmm'): [{ type: 'reasoning-start', id: '1' }, { type: 'reasoning-delta', id: '1', delta: 'Hmm' }, { type: 'reasoning-end', id: '1' }]
416
+ ```
417
+
418
+ #### `.toolInput(args)`
419
+
420
+ ```ts
421
+ StreamParts.toolInput(args: { id: string; toolName: string; input: unknown; length?: number }): LanguageModelV3StreamPart[]
422
+ // StreamParts.toolInput({ id: 't1', toolName: 'weather', input: { city: 'Tokyo' } }): [{ type: 'tool-input-start', id: 't1', toolName: 'weather' }, { type: 'tool-input-delta', id: 't1', delta: '{"city":"Tokyo"}' }, { type: 'tool-input-end', id: 't1' }]
423
+ ```
424
+
425
+ #### `.toolCall(args)`
426
+
427
+ ```ts
428
+ StreamParts.toolCall(args: { toolCallId: string; toolName: string; input: unknown }): LanguageModelV3StreamPart
429
+ // StreamParts.toolCall({ toolCallId: 'c1', toolName: 'weather', input: { city: 'Tokyo' } }): { type: 'tool-call', toolCallId: 'c1', toolName: 'weather', input: '{"city":"Tokyo"}' }
430
+ ```
431
+
432
+ #### `.toolResult(args)`
433
+
434
+ ```ts
435
+ StreamParts.toolResult(args: { toolCallId: string; toolName: string; result: unknown; isError?: boolean }): LanguageModelV3StreamPart
436
+ // StreamParts.toolResult({ toolCallId: 'c1', toolName: 'weather', result: { temp: 20 } }): { type: 'tool-result', toolCallId: 'c1', toolName: 'weather', result: { temp: 20 } }
437
+ ```
438
+
439
+ #### `.source(args)`
440
+
441
+ ```ts
442
+ StreamParts.source(args: { id: string; url: string; title?: string }): LanguageModelV3StreamPart
443
+ // StreamParts.source({ id: 's1', url: 'https://example.com' }): { type: 'source', sourceType: 'url', id: 's1', url: 'https://example.com' }
444
+ ```
445
+
446
+ #### `.file(args)`
447
+
448
+ ```ts
449
+ StreamParts.file(args: { mediaType: string; data: string | Uint8Array }): LanguageModelV3StreamPart
450
+ // StreamParts.file({ mediaType: 'image/png', data: 'abc' }): { type: 'file', mediaType: 'image/png', data: 'abc' }
451
+ ```
452
+
453
+ #### `.finish(args?)`
454
+
455
+ ```ts
456
+ StreamParts.finish(args?: { finishReason?: LanguageModelV3FinishReason | LanguageModelV3FinishReason['unified']; usage?: LanguageModelV3Usage }): LanguageModelV3StreamPart
457
+ // StreamParts.finish(): { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage }
458
+ ```
459
+
460
+ #### `.error(error)`
461
+
462
+ ```ts
463
+ StreamParts.error(error: unknown): LanguageModelV3StreamPart
464
+ // StreamParts.error(new Error('boom')): { type: 'error', error: Error('boom') }
465
+ ```
466
+
467
+ #### `.streamStart(warnings?)`
468
+
469
+ ```ts
470
+ StreamParts.streamStart(warnings?: SharedV3Warning[]): LanguageModelV3StreamPart
471
+ // StreamParts.streamStart(): { type: 'stream-start', warnings: [] }
472
+ ```
473
+
474
+ #### `.responseMetadata(meta?)`
475
+
476
+ ```ts
477
+ StreamParts.responseMetadata(meta?: LanguageModelV3ResponseMetadata): LanguageModelV3StreamPart
478
+ // StreamParts.responseMetadata({ id: 'r1' }): { type: 'response-metadata', id: 'r1' }
479
+ ```
480
+
481
+ #### `.raw(rawValue)`
482
+
483
+ ```ts
484
+ StreamParts.raw(rawValue: unknown): LanguageModelV3StreamPart
485
+ // StreamParts.raw({ foo: 1 }): { type: 'raw', rawValue: { foo: 1 } }
486
+ ```
487
+
488
+ #### `Stream`
489
+
490
+ Operations for building, draining, and inspecting streams.
491
+
492
+ #### `.from(parts)`
493
+
494
+ ```ts
495
+ Stream.from<T>(parts: T[]): ReadableStream<T>
496
+ // Stream.from([a, b]): ReadableStream emitting a, then b
497
+ ```
498
+
499
+ #### `.simulate(chunks, options?)`
500
+
501
+ ```ts
502
+ Stream.simulate<T>(chunks: T[], options?: StreamDelayOptions): ReadableStream<T>
503
+ // Stream.simulate([a, b], { chunkDelayInMs: 5 }): ReadableStream emitting a, then b, with delays
504
+ ```
505
+
506
+ #### `.toArray(stream)`
507
+
508
+ ```ts
509
+ Stream.toArray<T>(stream: ReadableStream<T>): Promise<T[]>
510
+ // Stream.toArray(stream): Promise<[a, b]>
511
+ ```
512
+
513
+ #### `.text(parts)`
514
+
515
+ ```ts
516
+ Stream.text(parts: LanguageModelV3StreamPart[]): string
517
+ // Stream.text(StreamParts.text('Hello World')): 'Hello World'
518
+ ```
519
+
520
+ #### `.finishReason(parts)`
521
+
522
+ ```ts
523
+ Stream.finishReason(parts: LanguageModelV3StreamPart[]): LanguageModelV3FinishReason | undefined
524
+ // Stream.finishReason([StreamParts.finish()]): { unified: 'stop', raw: 'stop' }
525
+ ```
526
+
527
+ #### `Options`
528
+
529
+ Determinism helpers to spread into `generateText()` / `streamText()`.
530
+
531
+ #### `.generateId()`
532
+
533
+ ```ts
534
+ Options.generateId(): string
535
+ // Options.generateId(): 'aitxt-mock-id'
536
+ ```
537
+
538
+ #### `.generate`
539
+
540
+ ```ts
541
+ Options.generate: { _internal: { generateId } }
542
+ // generateText({ model, prompt: 'x', ...Options.generate }): a deterministic generateId
543
+ ```
544
+
545
+ #### `.stream`
546
+
547
+ ```ts
548
+ Options.stream: { _internal: { generateId, now } }
549
+ // streamText({ model, prompt: 'x', ...Options.stream }): a deterministic generateId and now
550
+ ```
551
+
552
+ > [!NOTE]
553
+ > `Options.stream` pins timestamps via `_internal.now`, but the AI SDK uses `new Date()` directly on the `finish-step` part in the error streaming path. Tests that hit that path additionally need `vi.useFakeTimers()`.
554
+
555
+ ### UI Messages
556
+
557
+ Builders from `ai-test-kit/ui`. See [UI Messages](#ui-messages) under Usage for examples. By default `data`, `tool`, and metadata are loosely typed; bind them with [`fromUIMessage`](#fromuimessage).
558
+
559
+ #### `UIParts`
560
+
561
+ Builders for `UIMessagePart`.
562
+
563
+ #### `.text(text, options?)`
564
+
565
+ ```ts
566
+ UIParts.text(text: string, options?: { state?: 'streaming' | 'done'; providerMetadata?: ProviderMetadata }): TextUIPart
567
+ // UIParts.text('Hi'): { type: 'text', text: 'Hi' }
568
+ ```
569
+
570
+ #### `.reasoning(text, options?)`
571
+
572
+ ```ts
573
+ UIParts.reasoning(text: string, options?: { state?: 'streaming' | 'done'; providerMetadata?: ProviderMetadata }): ReasoningUIPart
574
+ // UIParts.reasoning('Hmm'): { type: 'reasoning', text: 'Hmm' }
575
+ ```
576
+
577
+ #### `.sourceUrl(args)`
578
+
579
+ ```ts
580
+ UIParts.sourceUrl(args: { sourceId: string; url: string; title?: string; providerMetadata?: ProviderMetadata }): SourceUrlUIPart
581
+ // UIParts.sourceUrl({ sourceId: 's1', url: 'https://example.com' }): { type: 'source-url', sourceId: 's1', url: 'https://example.com' }
582
+ ```
583
+
584
+ #### `.sourceDocument(args)`
585
+
586
+ ```ts
587
+ UIParts.sourceDocument(args: { sourceId: string; mediaType: string; title: string; filename?: string; providerMetadata?: ProviderMetadata }): SourceDocumentUIPart
588
+ // UIParts.sourceDocument({ sourceId: 's1', mediaType: 'application/pdf', title: 'Doc' }): { type: 'source-document', sourceId: 's1', mediaType: 'application/pdf', title: 'Doc' }
589
+ ```
590
+
591
+ #### `.file(args)`
592
+
593
+ ```ts
594
+ UIParts.file(args: { mediaType: string; filename?: string; url: string; providerMetadata?: ProviderMetadata }): FileUIPart
595
+ // UIParts.file({ mediaType: 'image/png', url: 'https://example.com/a.png' }): { type: 'file', mediaType: 'image/png', url: 'https://example.com/a.png' }
596
+ ```
597
+
598
+ #### `.stepStart()`
599
+
600
+ ```ts
601
+ UIParts.stepStart(): StepStartUIPart
602
+ // UIParts.stepStart(): { type: 'step-start' }
603
+ ```
604
+
605
+ #### `.data(name, data, options?)`
606
+
607
+ ```ts
608
+ UIParts.data(name: string, data: unknown, options?: { id?: string }): DataUIPart
609
+ // UIParts.data('weather', { city: 'Tokyo' }): { type: 'data-weather', data: { city: 'Tokyo' } }
610
+ ```
611
+
612
+ #### `.tool(name, invocation)`
613
+
614
+ ```ts
615
+ UIParts.tool(name: string, invocation: UIToolInvocation): ToolUIPart
616
+ // UIParts.tool('weather', { toolCallId: 'c1', state: 'output-available', input: { city: 'Tokyo' }, output: { temp: 20 } }): { type: 'tool-weather', toolCallId: 'c1', state: 'output-available', input: { city: 'Tokyo' }, output: { temp: 20 } }
617
+ ```
618
+
619
+ #### `.dynamicTool(invocation)`
620
+
621
+ ```ts
622
+ UIParts.dynamicTool(invocation: Omit<DynamicToolUIPart, 'type'>): DynamicToolUIPart
623
+ // UIParts.dynamicTool({ toolName: 'weather', toolCallId: 'c1', state: 'input-available', input: { city: 'Tokyo' } }): { type: 'dynamic-tool', toolName: 'weather', toolCallId: 'c1', state: 'input-available', input: { city: 'Tokyo' } }
624
+ ```
625
+
626
+ #### `UIChunks`
627
+
628
+ Builders for every `UIMessageChunk` variant; required fields shown, each also accepts its variant's optional fields (`providerMetadata`, `toolMetadata`, …). The `text`, `reasoning`, and `toolInput` block helpers return arrays.
629
+
630
+ #### `.textStart(args)`
631
+
632
+ ```ts
633
+ UIChunks.textStart(args: { id: string }): UIMessageChunk
634
+ // UIChunks.textStart({ id: '1' }): { type: 'text-start', id: '1' }
635
+ ```
636
+
637
+ #### `.textDelta(args)`
638
+
639
+ ```ts
640
+ UIChunks.textDelta(args: { id: string; delta: string }): UIMessageChunk
641
+ // UIChunks.textDelta({ id: '1', delta: 'Hi' }): { type: 'text-delta', id: '1', delta: 'Hi' }
642
+ ```
643
+
644
+ #### `.textEnd(args)`
645
+
646
+ ```ts
647
+ UIChunks.textEnd(args: { id: string }): UIMessageChunk
648
+ // UIChunks.textEnd({ id: '1' }): { type: 'text-end', id: '1' }
649
+ ```
650
+
651
+ #### `.reasoningStart(args)`
652
+
653
+ ```ts
654
+ UIChunks.reasoningStart(args: { id: string }): UIMessageChunk
655
+ // UIChunks.reasoningStart({ id: 'r1' }): { type: 'reasoning-start', id: 'r1' }
656
+ ```
657
+
658
+ #### `.reasoningDelta(args)`
659
+
660
+ ```ts
661
+ UIChunks.reasoningDelta(args: { id: string; delta: string }): UIMessageChunk
662
+ // UIChunks.reasoningDelta({ id: 'r1', delta: 'Hmm' }): { type: 'reasoning-delta', id: 'r1', delta: 'Hmm' }
663
+ ```
664
+
665
+ #### `.reasoningEnd(args)`
666
+
667
+ ```ts
668
+ UIChunks.reasoningEnd(args: { id: string }): UIMessageChunk
669
+ // UIChunks.reasoningEnd({ id: 'r1' }): { type: 'reasoning-end', id: 'r1' }
670
+ ```
671
+
672
+ #### `.error(errorText)`
673
+
674
+ ```ts
675
+ UIChunks.error(errorText: string): UIMessageChunk
676
+ // UIChunks.error('boom'): { type: 'error', errorText: 'boom' }
677
+ ```
678
+
679
+ #### `.toolInputStart(args)`
680
+
681
+ ```ts
682
+ UIChunks.toolInputStart(args: { toolCallId: string; toolName: string }): UIMessageChunk
683
+ // UIChunks.toolInputStart({ toolCallId: 'c1', toolName: 'weather' }): { type: 'tool-input-start', toolCallId: 'c1', toolName: 'weather' }
684
+ ```
685
+
686
+ #### `.toolInputDelta(args)`
687
+
688
+ ```ts
689
+ UIChunks.toolInputDelta(args: { toolCallId: string; inputTextDelta: string }): UIMessageChunk
690
+ // UIChunks.toolInputDelta({ toolCallId: 'c1', inputTextDelta: '{"city":' }): { type: 'tool-input-delta', toolCallId: 'c1', inputTextDelta: '{"city":' }
691
+ ```
692
+
693
+ #### `.toolInputAvailable(args)`
694
+
695
+ ```ts
696
+ UIChunks.toolInputAvailable(args: { toolCallId: string; toolName: string; input: unknown }): UIMessageChunk
697
+ // UIChunks.toolInputAvailable({ toolCallId: 'c1', toolName: 'weather', input: { city: 'Tokyo' } }): { type: 'tool-input-available', toolCallId: 'c1', toolName: 'weather', input: { city: 'Tokyo' } }
698
+ ```
699
+
700
+ #### `.toolInputError(args)`
701
+
702
+ ```ts
703
+ UIChunks.toolInputError(args: { toolCallId: string; toolName: string; input: unknown; errorText: string }): UIMessageChunk
704
+ // UIChunks.toolInputError({ toolCallId: 'c1', toolName: 'weather', input: {}, errorText: 'bad input' }): { type: 'tool-input-error', toolCallId: 'c1', toolName: 'weather', input: {}, errorText: 'bad input' }
705
+ ```
706
+
707
+ #### `.toolApprovalRequest(args)`
708
+
709
+ ```ts
710
+ UIChunks.toolApprovalRequest(args: { approvalId: string; toolCallId: string }): UIMessageChunk
711
+ // UIChunks.toolApprovalRequest({ approvalId: 'a1', toolCallId: 'c1' }): { type: 'tool-approval-request', approvalId: 'a1', toolCallId: 'c1' }
712
+ ```
713
+
714
+ #### `.toolOutputAvailable(args)`
715
+
716
+ ```ts
717
+ UIChunks.toolOutputAvailable(args: { toolCallId: string; output: unknown }): UIMessageChunk
718
+ // UIChunks.toolOutputAvailable({ toolCallId: 'c1', output: { temp: 20 } }): { type: 'tool-output-available', toolCallId: 'c1', output: { temp: 20 } }
719
+ ```
720
+
721
+ #### `.toolOutputError(args)`
722
+
723
+ ```ts
724
+ UIChunks.toolOutputError(args: { toolCallId: string; errorText: string }): UIMessageChunk
725
+ // UIChunks.toolOutputError({ toolCallId: 'c1', errorText: 'failed' }): { type: 'tool-output-error', toolCallId: 'c1', errorText: 'failed' }
726
+ ```
727
+
728
+ #### `.toolOutputDenied(args)`
729
+
730
+ ```ts
731
+ UIChunks.toolOutputDenied(args: { toolCallId: string }): UIMessageChunk
732
+ // UIChunks.toolOutputDenied({ toolCallId: 'c1' }): { type: 'tool-output-denied', toolCallId: 'c1' }
733
+ ```
734
+
735
+ #### `.sourceUrl(args)`
736
+
737
+ ```ts
738
+ UIChunks.sourceUrl(args: { sourceId: string; url: string; title?: string }): UIMessageChunk
739
+ // UIChunks.sourceUrl({ sourceId: 's1', url: 'https://example.com' }): { type: 'source-url', sourceId: 's1', url: 'https://example.com' }
740
+ ```
741
+
742
+ #### `.sourceDocument(args)`
743
+
744
+ ```ts
745
+ UIChunks.sourceDocument(args: { sourceId: string; mediaType: string; title: string; filename?: string }): UIMessageChunk
746
+ // UIChunks.sourceDocument({ sourceId: 's1', mediaType: 'application/pdf', title: 'Doc' }): { type: 'source-document', sourceId: 's1', mediaType: 'application/pdf', title: 'Doc' }
747
+ ```
748
+
749
+ #### `.file(args)`
750
+
751
+ ```ts
752
+ UIChunks.file(args: { url: string; mediaType: string }): UIMessageChunk
753
+ // UIChunks.file({ url: 'https://example.com/a.png', mediaType: 'image/png' }): { type: 'file', url: 'https://example.com/a.png', mediaType: 'image/png' }
754
+ ```
755
+
756
+ #### `.data(name, data, options?)`
757
+
758
+ ```ts
759
+ UIChunks.data(name: string, data: unknown, options?: { id?: string; transient?: boolean }): UIMessageChunk
760
+ // UIChunks.data('weather', { city: 'Tokyo' }): { type: 'data-weather', data: { city: 'Tokyo' } }
761
+ ```
762
+
763
+ #### `.startStep()`
764
+
765
+ ```ts
766
+ UIChunks.startStep(): UIMessageChunk
767
+ // UIChunks.startStep(): { type: 'start-step' }
768
+ ```
769
+
770
+ #### `.finishStep()`
771
+
772
+ ```ts
773
+ UIChunks.finishStep(): UIMessageChunk
774
+ // UIChunks.finishStep(): { type: 'finish-step' }
775
+ ```
776
+
777
+ #### `.start(args?)`
778
+
779
+ ```ts
780
+ UIChunks.start(args?: { messageId?: string; messageMetadata?: unknown }): UIMessageChunk
781
+ // UIChunks.start(): { type: 'start' }
782
+ ```
783
+
784
+ #### `.finish(args?)`
785
+
786
+ ```ts
787
+ UIChunks.finish(args?: { finishReason?: FinishReason; messageMetadata?: unknown }): UIMessageChunk
788
+ // UIChunks.finish(): { type: 'finish' }
789
+ ```
790
+
791
+ #### `.abort(args?)`
792
+
793
+ ```ts
794
+ UIChunks.abort(args?: { reason?: string }): UIMessageChunk
795
+ // UIChunks.abort({ reason: 'cancelled' }): { type: 'abort', reason: 'cancelled' }
796
+ ```
797
+
798
+ #### `.messageMetadata(metadata)`
799
+
800
+ ```ts
801
+ UIChunks.messageMetadata(metadata: unknown): UIMessageChunk
802
+ // UIChunks.messageMetadata({ traceId: 't1' }): { type: 'message-metadata', messageMetadata: { traceId: 't1' } }
803
+ ```
804
+
805
+ #### `.text(text, options?)`
806
+
807
+ ```ts
808
+ UIChunks.text(text: string, options?: UIChunkBlockOptions): UIMessageChunk[]
809
+ // UIChunks.text('Hi'): [{ type: 'text-start', id: '1' }, { type: 'text-delta', id: '1', delta: 'Hi' }, { type: 'text-end', id: '1' }]
810
+ ```
811
+
812
+ #### `.reasoning(text, options?)`
813
+
814
+ ```ts
815
+ UIChunks.reasoning(text: string, options?: UIChunkBlockOptions): UIMessageChunk[]
816
+ // UIChunks.reasoning('Hmm'): [{ type: 'reasoning-start', id: '1' }, { type: 'reasoning-delta', id: '1', delta: 'Hmm' }, { type: 'reasoning-end', id: '1' }]
817
+ ```
818
+
819
+ #### `.toolInput(args)`
820
+
821
+ ```ts
822
+ UIChunks.toolInput(args: { toolCallId: string; toolName: string; input: unknown; length?: number }): UIMessageChunk[]
823
+ // UIChunks.toolInput({ toolCallId: 'c1', toolName: 'weather', input: { city: 'Tokyo' } }): [{ type: 'tool-input-start', toolCallId: 'c1', toolName: 'weather' }, { type: 'tool-input-delta', toolCallId: 'c1', inputTextDelta: '{"city":"Tokyo"}' }, { type: 'tool-input-available', toolCallId: 'c1', toolName: 'weather', input: { city: 'Tokyo' } }]
824
+ ```
825
+
826
+ #### `UIMessages`
827
+
828
+ Builders for `UIMessage`. A `string` becomes a single text part; ids auto-increment (`mock-message-{n}`) when omitted.
829
+
830
+ #### `.user(content, options?)`
831
+
832
+ ```ts
833
+ UIMessages.user(content: string | UIMessagePart[], options?: { id?: string; metadata?: unknown }): UIMessage
834
+ // UIMessages.user('hi'): { id: 'mock-message-1', role: 'user', parts: [{ type: 'text', text: 'hi' }] }
835
+ // UIMessages.user([UIParts.text('hi')]): { id: 'mock-message-1', role: 'user', parts: [{ type: 'text', text: 'hi' }] } — array of parts passes through
836
+ ```
837
+
838
+ #### `.assistant(content, options?)`
839
+
840
+ ```ts
841
+ UIMessages.assistant(content: string | UIMessagePart[], options?: { id?: string; metadata?: unknown }): UIMessage
842
+ // UIMessages.assistant('Hi'): { id: 'mock-message-1', role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] }
843
+ // UIMessages.assistant([UIParts.text('Hi')], { id: 'm1' }): { id: 'm1', role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] } — array of parts passes through
844
+ ```
845
+
846
+ #### `.system(content, options?)`
847
+
848
+ ```ts
849
+ UIMessages.system(content: string | UIMessagePart[], options?: { id?: string; metadata?: unknown }): UIMessage
850
+ // UIMessages.system('Be concise', { id: 'm1' }): { id: 'm1', role: 'system', parts: [{ type: 'text', text: 'Be concise' }] }
851
+ // UIMessages.system([UIParts.text('Be concise')]): { id: 'mock-message-1', role: 'system', parts: [{ type: 'text', text: 'Be concise' }] } — array of parts passes through
852
+ ```
853
+
854
+ #### `fromUIMessage`
855
+
856
+ Binds the UI builders to a concrete `UIMessage` type so `data`, `tool`, and metadata infer their names and payloads. The `createUIParts` / `createUIChunks` / `createUIMessages` factories are also exported for binding type parameters directly.
857
+
858
+ ```ts
859
+ fromUIMessage<UIMessage>(): { UIParts; UIChunks; UIMessages }
860
+ // const { UIParts, UIChunks, UIMessages } = fromUIMessage<MyUIMessage>(): builders typed to MyUIMessage
861
+ ```
862
+
863
+ ## Types
864
+
865
+ All types are exported from `ai-test-kit/language`.
866
+
867
+ ### `MockResponse`
868
+
869
+ A single mock response. A `string` or `Error` applies to whichever method is called; the object forms target one method explicitly. Pass an `Array<MockResponse>` to sequence responses across calls.
870
+
871
+ ```ts
872
+ type MockResponse =
873
+ | string // text, for both generate and stream
874
+ | Error // both methods throw
875
+ | { content; finishReason?; usage? } // generate result, or a derived stream
876
+ | { generate?; stream? }; // generate and/or stream explicitly
877
+ ```
878
+
879
+ ### `MockLanguageModel`
880
+
881
+ The mock model instance type, as returned by `MockLanguageModel.from()`. Because the namespace and the instance type share the name, you can use `MockLanguageModel` to annotate a model parameter.
882
+
883
+ ```ts
884
+ import type { MockLanguageModel } from 'ai-test-kit/language';
885
+ ```
886
+
887
+ ### `GenerateResponse` / `StreamResponse`
888
+
889
+ The per-method response shapes used by the `{ generate, stream }` form of `MockResponse`. `stream` accepts a bare `Array<StreamPart>`, or a `{ chunks, initialDelayInMs?, chunkDelayInMs? }` object to simulate delays.
890
+
891
+ ```ts
892
+ import type { GenerateResponse, StreamResponse } from 'ai-test-kit/language';
893
+ ```
894
+
895
+ ### `MockLanguageModelOptions`
896
+
897
+ The identity overrides accepted as the second argument to `MockLanguageModel.from()`.
898
+
899
+ ```ts
900
+ import type { MockLanguageModelOptions } from 'ai-test-kit/language';
901
+ // { provider?: string; modelId?: string }
902
+ ```
903
+
904
+ ### `StreamPartOptions`
905
+
906
+ Options for the streamed-text part builders (`StreamParts.text` / `StreamParts.reasoning`).
907
+
908
+ ```ts
909
+ import type { StreamPartOptions } from 'ai-test-kit/language';
910
+ // { id?: string; length?: number; separator?: string }
911
+ ```
912
+
913
+ ### `StreamDelayOptions`
914
+
915
+ Simulated timing shared by `Stream.simulate`, `MockLanguageModel.streamResult`, and the `stream` chunks form.
916
+
917
+ ```ts
918
+ import type { StreamDelayOptions } from 'ai-test-kit/language';
919
+ // { initialDelayInMs?: number; chunkDelayInMs?: number }
920
+ ```
921
+
922
+ ## License
923
+
924
+ MIT