@outputai/llm 0.11.0 → 0.11.1-next.2223fa5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/agent.js +105 -83
- package/src/agent.spec.js +312 -225
- package/src/ai_provider.js +2 -2
- package/src/ai_sdk_options.js +93 -39
- package/src/ai_sdk_options.spec.js +314 -154
- package/src/consts.js +6 -0
- package/src/generate.js +81 -0
- package/src/generate.spec.js +466 -0
- package/src/index.d.ts +222 -242
- package/src/index.js +3 -4
- package/src/prompt/content.js +70 -0
- package/src/prompt/content.spec.js +208 -0
- package/src/prompt/{escape.js → interpolations.js} +11 -18
- package/src/prompt/interpolations.spec.js +109 -0
- package/src/prompt/loader.full.spec.js +293 -0
- package/src/prompt/loader.js +45 -39
- package/src/prompt/loader.spec.js +186 -289
- package/src/prompt/markup/attributes.js +52 -0
- package/src/prompt/markup/attributes.spec.js +132 -0
- package/src/prompt/markup/nodes.js +71 -0
- package/src/prompt/markup/nodes.spec.js +333 -0
- package/src/prompt/markup/tokenizer.js +3 -0
- package/src/prompt/markup/tokenizer.spec.js +149 -0
- package/src/prompt/markup/tokens.js +26 -0
- package/src/prompt/markup/tokens.spec.js +250 -0
- package/src/prompt/validations.js +98 -67
- package/src/prompt/validations.spec.js +204 -47
- package/src/{prompt/load_content.js → utils/file.js} +12 -15
- package/src/utils/file.spec.js +89 -0
- package/src/utils/models.js +15 -0
- package/src/utils/models.spec.js +119 -0
- package/src/utils/skills.js +74 -0
- package/src/utils/skills.spec.js +168 -0
- package/src/utils/sources.js +48 -0
- package/src/utils/sources.spec.js +122 -0
- package/src/utils/stream.js +22 -0
- package/src/utils/stream.spec.js +55 -0
- package/src/utils/tools.js +47 -0
- package/src/utils/tools.spec.js +167 -0
- package/src/utils/wrap.js +148 -0
- package/src/utils/wrap.spec.js +359 -0
- package/src/validations.js +149 -27
- package/src/validations.spec.js +451 -53
- package/src/ai_model.js +0 -60
- package/src/ai_model.spec.js +0 -259
- package/src/ai_sdk.js +0 -92
- package/src/ai_sdk.spec.js +0 -564
- package/src/prompt/block_options.js +0 -58
- package/src/prompt/block_options.spec.js +0 -71
- package/src/prompt/blocks.js +0 -47
- package/src/prompt/blocks.spec.js +0 -63
- package/src/prompt/escape.spec.js +0 -159
- package/src/prompt/load_content.spec.js +0 -83
- package/src/prompt/loader_validation.spec.js +0 -128
- package/src/prompt/parser.js +0 -16
- package/src/prompt/parser.spec.js +0 -186
- package/src/prompt/prepare_text.js +0 -27
- package/src/prompt/prepare_text.spec.js +0 -141
- package/src/prompt/skill.js +0 -128
- package/src/prompt/skill.spec.js +0 -172
- package/src/utils/message.js +0 -3
- package/src/utils/message.spec.js +0 -29
- package/src/utils/response_wrappers.js +0 -100
- package/src/utils/response_wrappers.spec.js +0 -240
- package/src/utils/source_extraction.js +0 -53
- package/src/utils/source_extraction.spec.js +0 -194
- package/src/utils/trace.js +0 -19
- package/src/utils/trace.spec.js +0 -112
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const aiFns = vi.hoisted( () => ( {
|
|
4
|
+
generateText: vi.fn(),
|
|
5
|
+
streamText: vi.fn(),
|
|
6
|
+
generateImage: vi.fn()
|
|
7
|
+
} ) );
|
|
8
|
+
|
|
9
|
+
const validations = vi.hoisted( () => ( {
|
|
10
|
+
parseGenerateTextArgs: vi.fn(),
|
|
11
|
+
parseGenerateTextWithStreamingArgs: vi.fn(),
|
|
12
|
+
parseStreamTextArgs: vi.fn(),
|
|
13
|
+
parseGenerateImageArgs: vi.fn()
|
|
14
|
+
} ) );
|
|
15
|
+
|
|
16
|
+
const toPromptFileArgs = ( { prompt, ...rest } ) => ( {
|
|
17
|
+
promptFile: prompt,
|
|
18
|
+
...rest
|
|
19
|
+
} );
|
|
20
|
+
|
|
21
|
+
const promptMocks = vi.hoisted( () => ( {
|
|
22
|
+
loadPrompt: vi.fn()
|
|
23
|
+
} ) );
|
|
24
|
+
|
|
25
|
+
const skillMocks = vi.hoisted( () => ( {
|
|
26
|
+
loadSkills: vi.fn()
|
|
27
|
+
} ) );
|
|
28
|
+
|
|
29
|
+
const optionMocks = vi.hoisted( () => ( {
|
|
30
|
+
loadAiSdkTextOptions: vi.fn(),
|
|
31
|
+
loadAiSdkImageOptions: vi.fn()
|
|
32
|
+
} ) );
|
|
33
|
+
|
|
34
|
+
const wrapMocks = vi.hoisted( () => ( {
|
|
35
|
+
wrapGeneration: vi.fn(),
|
|
36
|
+
wrapStream: vi.fn(),
|
|
37
|
+
streamHooks: { onFinishHook: vi.fn(), onErrorHook: vi.fn() }
|
|
38
|
+
} ) );
|
|
39
|
+
|
|
40
|
+
const streamMocks = vi.hoisted( () => ( {
|
|
41
|
+
drainStream: vi.fn()
|
|
42
|
+
} ) );
|
|
43
|
+
|
|
44
|
+
vi.mock( 'ai', () => aiFns );
|
|
45
|
+
|
|
46
|
+
vi.mock( './validations.js', () => validations );
|
|
47
|
+
|
|
48
|
+
vi.mock( './prompt/loader.js', () => ( {
|
|
49
|
+
loadPrompt: ( ...args ) => promptMocks.loadPrompt( ...args )
|
|
50
|
+
} ) );
|
|
51
|
+
|
|
52
|
+
vi.mock( './utils/skills.js', () => ( {
|
|
53
|
+
loadSkills: ( ...args ) => skillMocks.loadSkills( ...args )
|
|
54
|
+
} ) );
|
|
55
|
+
|
|
56
|
+
vi.mock( './ai_sdk_options.js', () => ( {
|
|
57
|
+
loadAiSdkTextOptions: ( ...args ) => optionMocks.loadAiSdkTextOptions( ...args ),
|
|
58
|
+
loadAiSdkImageOptions: ( ...args ) => optionMocks.loadAiSdkImageOptions( ...args )
|
|
59
|
+
} ) );
|
|
60
|
+
|
|
61
|
+
vi.mock( './utils/wrap.js', () => ( {
|
|
62
|
+
wrapGeneration: ( ...args ) => wrapMocks.wrapGeneration( ...args ),
|
|
63
|
+
wrapStream: ( ...args ) => wrapMocks.wrapStream( ...args )
|
|
64
|
+
} ) );
|
|
65
|
+
|
|
66
|
+
vi.mock( './utils/stream.js', () => ( {
|
|
67
|
+
drainStream: ( ...args ) => streamMocks.drainStream( ...args )
|
|
68
|
+
} ) );
|
|
69
|
+
|
|
70
|
+
const importSut = async () => import( './generate.js' );
|
|
71
|
+
|
|
72
|
+
const loadedPrompt = {
|
|
73
|
+
name: 'test@v1',
|
|
74
|
+
config: { provider: 'openai', model: 'test-model' },
|
|
75
|
+
messages: [ { role: 'user', content: 'Hello' } ]
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const loadedSkills = [ { name: 'writer', description: 'Writes', instructions: 'Do it.' } ];
|
|
79
|
+
|
|
80
|
+
const textOptions = {
|
|
81
|
+
model: 'MODEL',
|
|
82
|
+
messages: loadedPrompt.messages,
|
|
83
|
+
providerOptions: { test: true }
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const textResponse = {
|
|
87
|
+
text: 'TEXT',
|
|
88
|
+
totalUsage: { inputTokens: 1, outputTokens: 2 },
|
|
89
|
+
finishReason: 'stop'
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const streamResult = {
|
|
93
|
+
textStream: 'TEXT_STREAM',
|
|
94
|
+
fullStream: 'FULL_STREAM'
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const imageOptions = {
|
|
98
|
+
model: 'IMAGE_MODEL',
|
|
99
|
+
prompt: {
|
|
100
|
+
text: 'Generate an image'
|
|
101
|
+
},
|
|
102
|
+
providerOptions: { openai: { quality: 'high' } }
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const imageResponse = {
|
|
106
|
+
images: [ { mediaType: 'image/png', base64: 'aW1hZ2U=' } ],
|
|
107
|
+
usage: { inputTokens: 1, outputTokens: 2 }
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
describe( 'generate', () => {
|
|
111
|
+
beforeEach( () => {
|
|
112
|
+
aiFns.generateText.mockReset().mockResolvedValue( textResponse );
|
|
113
|
+
aiFns.streamText.mockReset().mockReturnValue( streamResult );
|
|
114
|
+
aiFns.generateImage.mockReset().mockResolvedValue( imageResponse );
|
|
115
|
+
|
|
116
|
+
validations.parseGenerateTextArgs.mockReset().mockImplementation( toPromptFileArgs );
|
|
117
|
+
validations.parseGenerateTextWithStreamingArgs.mockReset().mockImplementation( toPromptFileArgs );
|
|
118
|
+
validations.parseStreamTextArgs.mockReset().mockImplementation( toPromptFileArgs );
|
|
119
|
+
validations.parseGenerateImageArgs.mockReset().mockImplementation( toPromptFileArgs );
|
|
120
|
+
|
|
121
|
+
promptMocks.loadPrompt.mockReset().mockReturnValue( loadedPrompt );
|
|
122
|
+
skillMocks.loadSkills.mockReset().mockReturnValue( loadedSkills );
|
|
123
|
+
|
|
124
|
+
optionMocks.loadAiSdkTextOptions.mockReset().mockReturnValue( textOptions );
|
|
125
|
+
optionMocks.loadAiSdkImageOptions.mockReset().mockReturnValue( imageOptions );
|
|
126
|
+
|
|
127
|
+
wrapMocks.wrapGeneration.mockReset().mockImplementation( async ( { fn } ) => fn() );
|
|
128
|
+
wrapMocks.streamHooks = {
|
|
129
|
+
onFinishHook: vi.fn( async ( response, callback ) => callback?.( response ) ),
|
|
130
|
+
onErrorHook: vi.fn( ( event, callback ) => callback?.( event.error ) )
|
|
131
|
+
};
|
|
132
|
+
wrapMocks.wrapStream.mockReset().mockImplementation( ( { fn } ) => fn( wrapMocks.streamHooks ) );
|
|
133
|
+
|
|
134
|
+
streamMocks.drainStream.mockReset().mockResolvedValue( undefined );
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
afterEach( async () => {
|
|
138
|
+
await vi.resetModules();
|
|
139
|
+
} );
|
|
140
|
+
|
|
141
|
+
describe( 'generateText', () => {
|
|
142
|
+
it( 'parses args, loads prompt skills, wraps generation, and calls AI SDK', async () => {
|
|
143
|
+
const { generateText } = await importSut();
|
|
144
|
+
const variables = { topic: 'testing' };
|
|
145
|
+
const tools = { userTool: true };
|
|
146
|
+
const output = { type: 'object' };
|
|
147
|
+
const toolChoice = 'required';
|
|
148
|
+
const stopWhen = { type: 'custom-stop' };
|
|
149
|
+
const abortSignal = new AbortController().signal;
|
|
150
|
+
|
|
151
|
+
const result = await generateText( {
|
|
152
|
+
prompt: 'test@v1',
|
|
153
|
+
variables,
|
|
154
|
+
promptDir: '/prompts',
|
|
155
|
+
tools,
|
|
156
|
+
output,
|
|
157
|
+
toolChoice,
|
|
158
|
+
stopWhen,
|
|
159
|
+
abortSignal
|
|
160
|
+
} );
|
|
161
|
+
|
|
162
|
+
expect( validations.parseGenerateTextArgs ).toHaveBeenCalledWith( {
|
|
163
|
+
prompt: 'test@v1',
|
|
164
|
+
variables,
|
|
165
|
+
promptDir: '/prompts',
|
|
166
|
+
tools,
|
|
167
|
+
output,
|
|
168
|
+
toolChoice,
|
|
169
|
+
stopWhen,
|
|
170
|
+
abortSignal
|
|
171
|
+
} );
|
|
172
|
+
expect( promptMocks.loadPrompt ).toHaveBeenCalledWith( 'test@v1', variables, '/prompts' );
|
|
173
|
+
expect( skillMocks.loadSkills ).toHaveBeenCalledWith( loadedPrompt );
|
|
174
|
+
expect( wrapMocks.wrapGeneration ).toHaveBeenCalledWith( {
|
|
175
|
+
name: 'generateText',
|
|
176
|
+
prompt: loadedPrompt,
|
|
177
|
+
fn: expect.any( Function )
|
|
178
|
+
} );
|
|
179
|
+
expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( {
|
|
180
|
+
prompt: loadedPrompt,
|
|
181
|
+
skills: loadedSkills,
|
|
182
|
+
tools,
|
|
183
|
+
output,
|
|
184
|
+
toolChoice,
|
|
185
|
+
stopWhen,
|
|
186
|
+
abortSignal
|
|
187
|
+
} );
|
|
188
|
+
expect( aiFns.generateText ).toHaveBeenCalledWith( textOptions );
|
|
189
|
+
expect( result ).toBe( textResponse );
|
|
190
|
+
} );
|
|
191
|
+
|
|
192
|
+
it( 'propagates parse errors before wrapping or calling AI SDK', async () => {
|
|
193
|
+
const validationError = new Error( 'Invalid args' );
|
|
194
|
+
validations.parseGenerateTextArgs.mockImplementationOnce( () => {
|
|
195
|
+
throw validationError;
|
|
196
|
+
} );
|
|
197
|
+
const { generateText } = await importSut();
|
|
198
|
+
|
|
199
|
+
await expect( generateText( { prompt: '' } ) ).rejects.toThrow( validationError );
|
|
200
|
+
expect( promptMocks.loadPrompt ).not.toHaveBeenCalled();
|
|
201
|
+
expect( wrapMocks.wrapGeneration ).not.toHaveBeenCalled();
|
|
202
|
+
expect( aiFns.generateText ).not.toHaveBeenCalled();
|
|
203
|
+
} );
|
|
204
|
+
} );
|
|
205
|
+
|
|
206
|
+
describe( 'generateTextWithStreaming', () => {
|
|
207
|
+
it( 'consumes the stream inside wrapGeneration and returns the completed response', async () => {
|
|
208
|
+
const { generateTextWithStreaming } = await importSut();
|
|
209
|
+
const variables = { topic: 'testing' };
|
|
210
|
+
const output = { summary: 'Structured result' };
|
|
211
|
+
const chunk = { type: 'text-delta', text: 'TEXT' };
|
|
212
|
+
const onChunk = vi.fn();
|
|
213
|
+
const stream = { output: Promise.resolve( output ) };
|
|
214
|
+
const response = { ...textResponse };
|
|
215
|
+
aiFns.streamText.mockImplementationOnce( options => {
|
|
216
|
+
options.onChunk( { chunk } );
|
|
217
|
+
options.onFinish( response );
|
|
218
|
+
return stream;
|
|
219
|
+
} );
|
|
220
|
+
|
|
221
|
+
const result = await generateTextWithStreaming( {
|
|
222
|
+
prompt: 'test@v1',
|
|
223
|
+
variables,
|
|
224
|
+
promptDir: '/prompts',
|
|
225
|
+
onChunk
|
|
226
|
+
} );
|
|
227
|
+
|
|
228
|
+
expect( validations.parseGenerateTextWithStreamingArgs ).toHaveBeenCalledWith( {
|
|
229
|
+
prompt: 'test@v1',
|
|
230
|
+
variables,
|
|
231
|
+
promptDir: '/prompts',
|
|
232
|
+
onChunk
|
|
233
|
+
} );
|
|
234
|
+
expect( wrapMocks.wrapGeneration ).toHaveBeenCalledWith( {
|
|
235
|
+
name: 'generateTextWithStreaming',
|
|
236
|
+
prompt: loadedPrompt,
|
|
237
|
+
fn: expect.any( Function )
|
|
238
|
+
} );
|
|
239
|
+
expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( {
|
|
240
|
+
prompt: loadedPrompt,
|
|
241
|
+
skills: loadedSkills
|
|
242
|
+
} );
|
|
243
|
+
expect( aiFns.streamText ).toHaveBeenCalledWith( {
|
|
244
|
+
...textOptions,
|
|
245
|
+
onChunk,
|
|
246
|
+
onFinish: expect.any( Function ),
|
|
247
|
+
onError: expect.any( Function )
|
|
248
|
+
} );
|
|
249
|
+
expect( streamMocks.drainStream ).toHaveBeenCalledWith( stream, undefined );
|
|
250
|
+
expect( onChunk ).toHaveBeenCalledWith( { chunk } );
|
|
251
|
+
expect( result ).toEqual( { ...textResponse, output } );
|
|
252
|
+
} );
|
|
253
|
+
|
|
254
|
+
it( 'omits onChunk when the caller does not provide it', async () => {
|
|
255
|
+
const { generateTextWithStreaming } = await importSut();
|
|
256
|
+
const stream = { output: Promise.resolve( undefined ) };
|
|
257
|
+
aiFns.streamText.mockImplementationOnce( options => {
|
|
258
|
+
options.onFinish( { ...textResponse } );
|
|
259
|
+
return stream;
|
|
260
|
+
} );
|
|
261
|
+
|
|
262
|
+
await generateTextWithStreaming( { prompt: 'test@v1' } );
|
|
263
|
+
const callOptions = aiFns.streamText.mock.calls[0][0];
|
|
264
|
+
|
|
265
|
+
expect( callOptions ).not.toHaveProperty( 'onChunk' );
|
|
266
|
+
} );
|
|
267
|
+
|
|
268
|
+
it( 'rejects with the abort reason', async () => {
|
|
269
|
+
const abortController = new AbortController();
|
|
270
|
+
const abortReason = new Error( 'Cancelled by caller' );
|
|
271
|
+
const stream = { output: Promise.resolve( undefined ) };
|
|
272
|
+
abortController.abort( abortReason );
|
|
273
|
+
streamMocks.drainStream.mockRejectedValueOnce( abortReason );
|
|
274
|
+
aiFns.streamText.mockReturnValueOnce( stream );
|
|
275
|
+
const { generateTextWithStreaming } = await importSut();
|
|
276
|
+
|
|
277
|
+
await expect( generateTextWithStreaming( {
|
|
278
|
+
prompt: 'test@v1',
|
|
279
|
+
abortSignal: abortController.signal
|
|
280
|
+
} ) ).rejects.toBe( abortReason );
|
|
281
|
+
|
|
282
|
+
expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( {
|
|
283
|
+
prompt: loadedPrompt,
|
|
284
|
+
skills: loadedSkills,
|
|
285
|
+
abortSignal: abortController.signal
|
|
286
|
+
} );
|
|
287
|
+
expect( streamMocks.drainStream ).toHaveBeenCalledWith( stream, abortController.signal );
|
|
288
|
+
} );
|
|
289
|
+
} );
|
|
290
|
+
|
|
291
|
+
describe( 'streamText', () => {
|
|
292
|
+
it( 'parses args, loads prompt skills, wraps the stream, and calls AI SDK', async () => {
|
|
293
|
+
const { streamText } = await importSut();
|
|
294
|
+
const variables = { topic: 'testing' };
|
|
295
|
+
const onFinish = vi.fn();
|
|
296
|
+
const onChunk = vi.fn();
|
|
297
|
+
const tools = { userTool: true };
|
|
298
|
+
|
|
299
|
+
const result = streamText( {
|
|
300
|
+
prompt: 'test@v1',
|
|
301
|
+
variables,
|
|
302
|
+
promptDir: '/prompts',
|
|
303
|
+
onFinish,
|
|
304
|
+
onChunk,
|
|
305
|
+
tools
|
|
306
|
+
} );
|
|
307
|
+
|
|
308
|
+
expect( validations.parseStreamTextArgs ).toHaveBeenCalledWith( {
|
|
309
|
+
prompt: 'test@v1',
|
|
310
|
+
variables,
|
|
311
|
+
promptDir: '/prompts',
|
|
312
|
+
onFinish,
|
|
313
|
+
onChunk,
|
|
314
|
+
tools
|
|
315
|
+
} );
|
|
316
|
+
expect( promptMocks.loadPrompt ).toHaveBeenCalledWith( 'test@v1', variables, '/prompts' );
|
|
317
|
+
expect( skillMocks.loadSkills ).toHaveBeenCalledWith( loadedPrompt );
|
|
318
|
+
expect( wrapMocks.wrapStream ).toHaveBeenCalledWith( {
|
|
319
|
+
name: 'streamText',
|
|
320
|
+
prompt: loadedPrompt,
|
|
321
|
+
fn: expect.any( Function )
|
|
322
|
+
} );
|
|
323
|
+
expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( {
|
|
324
|
+
prompt: loadedPrompt,
|
|
325
|
+
skills: loadedSkills,
|
|
326
|
+
tools
|
|
327
|
+
} );
|
|
328
|
+
expect( aiFns.streamText ).toHaveBeenCalledWith( {
|
|
329
|
+
...textOptions,
|
|
330
|
+
onChunk,
|
|
331
|
+
onFinish: expect.any( Function ),
|
|
332
|
+
onError: expect.any( Function )
|
|
333
|
+
} );
|
|
334
|
+
const callOptions = aiFns.streamText.mock.calls[0][0];
|
|
335
|
+
await callOptions.onFinish( textResponse );
|
|
336
|
+
expect( wrapMocks.streamHooks.onFinishHook ).toHaveBeenCalledWith( textResponse, onFinish );
|
|
337
|
+
expect( onFinish ).toHaveBeenCalledWith( textResponse );
|
|
338
|
+
expect( result ).toBe( streamResult );
|
|
339
|
+
} );
|
|
340
|
+
|
|
341
|
+
it( 'omits onChunk when the caller does not provide it', async () => {
|
|
342
|
+
const { streamText } = await importSut();
|
|
343
|
+
|
|
344
|
+
streamText( { prompt: 'test@v1' } );
|
|
345
|
+
const callOptions = aiFns.streamText.mock.calls[0][0];
|
|
346
|
+
|
|
347
|
+
expect( callOptions ).not.toHaveProperty( 'onChunk' );
|
|
348
|
+
} );
|
|
349
|
+
|
|
350
|
+
it( 'lets caller stopWhen reach the options loader', async () => {
|
|
351
|
+
const { streamText } = await importSut();
|
|
352
|
+
const stopWhen = { type: 'custom-stop' };
|
|
353
|
+
|
|
354
|
+
streamText( { prompt: 'test@v1', stopWhen } );
|
|
355
|
+
|
|
356
|
+
expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( {
|
|
357
|
+
prompt: loadedPrompt,
|
|
358
|
+
skills: loadedSkills,
|
|
359
|
+
stopWhen
|
|
360
|
+
} );
|
|
361
|
+
expect( aiFns.streamText ).toHaveBeenCalledWith( expect.objectContaining( textOptions ) );
|
|
362
|
+
} );
|
|
363
|
+
|
|
364
|
+
it( 'forwards stream onError through wrapStream with the mapped event payload', async () => {
|
|
365
|
+
const { streamText } = await importSut();
|
|
366
|
+
const onError = vi.fn();
|
|
367
|
+
const error = new Error( 'Stream failed' );
|
|
368
|
+
const mappedError = new Error( 'Mapped stream failed' );
|
|
369
|
+
|
|
370
|
+
streamText( { prompt: 'test@v1', onError } );
|
|
371
|
+
const callOptions = aiFns.streamText.mock.calls[0][0];
|
|
372
|
+
callOptions.onError( { error, extra: true } );
|
|
373
|
+
|
|
374
|
+
expect( wrapMocks.streamHooks.onErrorHook ).toHaveBeenCalledWith(
|
|
375
|
+
{ error, extra: true },
|
|
376
|
+
expect.any( Function )
|
|
377
|
+
);
|
|
378
|
+
wrapMocks.streamHooks.onErrorHook.mock.calls[0][1]( mappedError );
|
|
379
|
+
expect( onError ).toHaveBeenCalledWith( { error: mappedError, extra: true } );
|
|
380
|
+
} );
|
|
381
|
+
|
|
382
|
+
it( 'propagates validation errors before loading or wrapping', async () => {
|
|
383
|
+
const validationError = new Error( 'Invalid args' );
|
|
384
|
+
validations.parseStreamTextArgs.mockImplementationOnce( () => {
|
|
385
|
+
throw validationError;
|
|
386
|
+
} );
|
|
387
|
+
const { streamText } = await importSut();
|
|
388
|
+
|
|
389
|
+
expect( () => streamText( { prompt: '' } ) ).toThrow( validationError );
|
|
390
|
+
expect( promptMocks.loadPrompt ).not.toHaveBeenCalled();
|
|
391
|
+
expect( skillMocks.loadSkills ).not.toHaveBeenCalled();
|
|
392
|
+
expect( wrapMocks.wrapStream ).not.toHaveBeenCalled();
|
|
393
|
+
expect( aiFns.streamText ).not.toHaveBeenCalled();
|
|
394
|
+
} );
|
|
395
|
+
} );
|
|
396
|
+
|
|
397
|
+
describe( 'generateImage', () => {
|
|
398
|
+
it( 'parses args, loads prompt, wraps generation, and calls AI SDK', async () => {
|
|
399
|
+
const { generateImage } = await importSut();
|
|
400
|
+
const variables = { scene: 'race cars' };
|
|
401
|
+
const images = [ Buffer.from( 'image-bytes' ) ];
|
|
402
|
+
const mask = Buffer.from( 'mask-bytes' );
|
|
403
|
+
const abortSignal = AbortSignal.abort();
|
|
404
|
+
|
|
405
|
+
const result = await generateImage( {
|
|
406
|
+
prompt: 'image@v1',
|
|
407
|
+
variables,
|
|
408
|
+
promptDir: '/prompts',
|
|
409
|
+
images,
|
|
410
|
+
mask,
|
|
411
|
+
abortSignal
|
|
412
|
+
} );
|
|
413
|
+
|
|
414
|
+
expect( validations.parseGenerateImageArgs ).toHaveBeenCalledWith( {
|
|
415
|
+
prompt: 'image@v1',
|
|
416
|
+
variables,
|
|
417
|
+
promptDir: '/prompts',
|
|
418
|
+
images,
|
|
419
|
+
mask,
|
|
420
|
+
abortSignal
|
|
421
|
+
} );
|
|
422
|
+
expect( promptMocks.loadPrompt ).toHaveBeenCalledWith( 'image@v1', variables, '/prompts' );
|
|
423
|
+
expect( skillMocks.loadSkills ).not.toHaveBeenCalled();
|
|
424
|
+
expect( wrapMocks.wrapGeneration ).toHaveBeenCalledWith( {
|
|
425
|
+
name: 'generateImage',
|
|
426
|
+
prompt: loadedPrompt,
|
|
427
|
+
fn: expect.any( Function )
|
|
428
|
+
} );
|
|
429
|
+
expect( optionMocks.loadAiSdkImageOptions ).toHaveBeenCalledWith( {
|
|
430
|
+
prompt: loadedPrompt,
|
|
431
|
+
images,
|
|
432
|
+
mask,
|
|
433
|
+
abortSignal
|
|
434
|
+
} );
|
|
435
|
+
expect( aiFns.generateImage ).toHaveBeenCalledWith( imageOptions );
|
|
436
|
+
expect( result ).toBe( imageResponse );
|
|
437
|
+
} );
|
|
438
|
+
|
|
439
|
+
it( 'supports text-to-image calls without images or mask', async () => {
|
|
440
|
+
const { generateImage } = await importSut();
|
|
441
|
+
|
|
442
|
+
await generateImage( { prompt: 'image@v1' } );
|
|
443
|
+
|
|
444
|
+
expect( validations.parseGenerateImageArgs ).toHaveBeenCalledWith( {
|
|
445
|
+
prompt: 'image@v1'
|
|
446
|
+
} );
|
|
447
|
+
expect( optionMocks.loadAiSdkImageOptions ).toHaveBeenCalledWith( {
|
|
448
|
+
prompt: loadedPrompt
|
|
449
|
+
} );
|
|
450
|
+
expect( aiFns.generateImage ).toHaveBeenCalledWith( imageOptions );
|
|
451
|
+
} );
|
|
452
|
+
|
|
453
|
+
it( 'propagates parse errors before loading or wrapping', async () => {
|
|
454
|
+
const validationError = new Error( 'Invalid image args' );
|
|
455
|
+
validations.parseGenerateImageArgs.mockImplementationOnce( () => {
|
|
456
|
+
throw validationError;
|
|
457
|
+
} );
|
|
458
|
+
const { generateImage } = await importSut();
|
|
459
|
+
|
|
460
|
+
await expect( generateImage( { prompt: '' } ) ).rejects.toThrow( validationError );
|
|
461
|
+
expect( promptMocks.loadPrompt ).not.toHaveBeenCalled();
|
|
462
|
+
expect( wrapMocks.wrapGeneration ).not.toHaveBeenCalled();
|
|
463
|
+
expect( aiFns.generateImage ).not.toHaveBeenCalled();
|
|
464
|
+
} );
|
|
465
|
+
} );
|
|
466
|
+
} );
|