@outputai/llm 0.6.1-dev.daae905.0 → 0.6.1-next.2cc4685.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +2 -2
  2. package/src/agent.js +15 -9
  3. package/src/agent.spec.js +295 -214
  4. package/src/ai_model.js +79 -36
  5. package/src/ai_model.spec.js +31 -13
  6. package/src/ai_sdk.js +55 -79
  7. package/src/ai_sdk.spec.js +464 -611
  8. package/src/ai_sdk_options.js +61 -0
  9. package/src/ai_sdk_options.spec.js +164 -0
  10. package/src/cost/index.js +1 -1
  11. package/src/index.d.ts +230 -175
  12. package/src/index.js +2 -2
  13. package/src/prompt/escape.js +65 -0
  14. package/src/prompt/escape.spec.js +159 -0
  15. package/src/{load_content.js → prompt/load_content.js} +1 -22
  16. package/src/{load_content.spec.js → prompt/load_content.spec.js} +6 -6
  17. package/src/prompt/loader.js +49 -0
  18. package/src/prompt/loader.spec.js +274 -0
  19. package/src/{prompt_loader_validation.spec.js → prompt/loader_validation.spec.js} +40 -7
  20. package/src/prompt/parser.js +19 -0
  21. package/src/{parser.spec.js → prompt/parser.spec.js} +74 -29
  22. package/src/prompt/prepare_text.js +27 -0
  23. package/src/prompt/prepare_text.spec.js +141 -0
  24. package/src/{skill.js → prompt/skill.js} +19 -0
  25. package/src/prompt/skill.spec.js +172 -0
  26. package/src/{prompt_validations.js → prompt/validations.js} +32 -6
  27. package/src/{prompt_validations.spec.js → prompt/validations.spec.js} +189 -1
  28. package/src/utils/__fixtures__/image_response.json +38 -0
  29. package/src/utils/__fixtures__/stream_response.json +294 -0
  30. package/src/utils/__fixtures__/text_response.json +201 -0
  31. package/src/utils/error_handler.js +65 -0
  32. package/src/utils/error_handler.spec.js +195 -0
  33. package/src/utils/image.js +10 -0
  34. package/src/utils/image.spec.js +20 -0
  35. package/src/utils/response_wrappers.js +46 -19
  36. package/src/utils/response_wrappers.spec.js +130 -70
  37. package/src/utils/source_extraction.js +17 -27
  38. package/src/utils/trace.js +2 -3
  39. package/src/utils/trace.spec.js +9 -13
  40. package/src/validations.js +54 -2
  41. package/src/validations.spec.js +166 -0
  42. package/src/parser.js +0 -28
  43. package/src/prompt_loader.js +0 -80
  44. package/src/prompt_loader.spec.js +0 -358
  45. package/src/skill.d.ts +0 -49
@@ -1,13 +1,58 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
+ const aiFns = vi.hoisted( () => ( {
4
+ generateText: vi.fn(),
5
+ streamText: vi.fn(),
6
+ generateImage: vi.fn(),
7
+ stepCountIs: vi.fn( count => ( { type: 'step-count', count } ) )
8
+ } ) );
9
+
10
+ const validators = vi.hoisted( () => ( {
11
+ validateGenerateTextArgs: vi.fn(),
12
+ validateStreamTextArgs: vi.fn(),
13
+ validateGenerateImageArgs: vi.fn()
14
+ } ) );
15
+
16
+ const promptMocks = vi.hoisted( () => ( {
17
+ loadPrompt: vi.fn(),
18
+ prepareTextPrompt: vi.fn()
19
+ } ) );
20
+
21
+ const optionMocks = vi.hoisted( () => ( {
22
+ loadAiSdkTextOptions: vi.fn(),
23
+ loadAiSdkImageOptions: vi.fn()
24
+ } ) );
25
+
3
26
  const traceMocks = vi.hoisted( () => ( {
4
- startTrace: vi.fn( () => 'trace-id' ),
27
+ startTrace: vi.fn(),
5
28
  endTraceWithError: vi.fn()
6
29
  } ) );
7
30
 
8
31
  const wrapMocks = vi.hoisted( () => ( {
9
32
  wrapTextResponse: vi.fn(),
10
- wrapStreamOnFinishResponse: vi.fn()
33
+ wrapStreamOnFinishResponse: vi.fn(),
34
+ wrapImageResponse: vi.fn()
35
+ } ) );
36
+
37
+ const errorMocks = vi.hoisted( () => ( {
38
+ mapAiError: vi.fn( error => error )
39
+ } ) );
40
+
41
+ vi.mock( 'ai', () => aiFns );
42
+
43
+ vi.mock( './validations.js', () => validators );
44
+
45
+ vi.mock( './prompt/loader.js', () => ( {
46
+ loadPrompt: ( ...args ) => promptMocks.loadPrompt( ...args )
47
+ } ) );
48
+
49
+ vi.mock( './prompt/prepare_text.js', () => ( {
50
+ prepareTextPrompt: ( ...args ) => promptMocks.prepareTextPrompt( ...args )
51
+ } ) );
52
+
53
+ vi.mock( './ai_sdk_options.js', () => ( {
54
+ loadAiSdkTextOptions: ( ...args ) => optionMocks.loadAiSdkTextOptions( ...args ),
55
+ loadAiSdkImageOptions: ( ...args ) => optionMocks.loadAiSdkImageOptions( ...args )
11
56
  } ) );
12
57
 
13
58
  vi.mock( './utils/trace.js', () => ( {
@@ -17,688 +62,496 @@ vi.mock( './utils/trace.js', () => ( {
17
62
 
18
63
  vi.mock( './utils/response_wrappers.js', () => ( {
19
64
  wrapTextResponse: ( ...args ) => wrapMocks.wrapTextResponse( ...args ),
20
- wrapStreamOnFinishResponse: ( ...args ) => wrapMocks.wrapStreamOnFinishResponse( ...args )
65
+ wrapStreamOnFinishResponse: ( ...args ) => wrapMocks.wrapStreamOnFinishResponse( ...args ),
66
+ wrapImageResponse: ( ...args ) => wrapMocks.wrapImageResponse( ...args )
21
67
  } ) );
22
68
 
23
- const loadModelImpl = vi.fn();
24
- const loadToolsImpl = vi.fn();
25
- vi.mock( './ai_model.js', () => ( {
26
- loadModel: ( ...values ) => loadModelImpl( ...values ),
27
- loadTools: ( ...values ) => loadToolsImpl( ...values )
69
+ vi.mock( './utils/error_handler.js', () => ( {
70
+ mapAiError: ( ...args ) => errorMocks.mapAiError( ...args )
28
71
  } ) );
29
72
 
30
- const aiFns = {
31
- generateText: vi.fn(),
32
- streamText: vi.fn(),
33
- tool: vi.fn( def => def ),
34
- stepCountIs: vi.fn( n => ( { type: 'stepCount', count: n } ) )
35
- };
36
- vi.mock( 'ai', () => ( aiFns ) );
73
+ const importSut = async () => import( './ai_sdk.js' );
37
74
 
38
- const validators = {
39
- validateGenerateTextArgs: vi.fn(),
40
- validateStreamTextArgs: vi.fn()
75
+ const loadedPrompt = {
76
+ name: 'test@v1',
77
+ config: { model: 'test-model' },
78
+ messages: [ { role: 'user', content: 'Hello' } ]
41
79
  };
42
- vi.mock( './validations.js', () => ( validators ) );
43
80
 
44
- const loadPromptImpl = vi.fn();
45
- vi.mock( './prompt_loader.js', () => ( {
46
- loadPrompt: ( ...values ) => loadPromptImpl( ...values )
47
- } ) );
81
+ const textOptions = {
82
+ model: 'MODEL',
83
+ messages: loadedPrompt.messages,
84
+ providerOptions: { test: true }
85
+ };
48
86
 
49
- const loadPromptSkillsImpl = vi.fn();
50
- const loadColocatedSkillsImpl = vi.fn().mockReturnValue( [] );
51
- vi.mock( './skill.js', async importOriginal => {
52
- const original = await importOriginal();
53
- return {
54
- ...original,
55
- loadPromptSkills: ( ...args ) => loadPromptSkillsImpl( ...args ),
56
- loadColocatedSkills: ( ...args ) => loadColocatedSkillsImpl( ...args )
57
- };
58
- } );
87
+ const textResponse = {
88
+ text: 'TEXT',
89
+ totalUsage: { inputTokens: 1, outputTokens: 2 },
90
+ finishReason: 'stop'
91
+ };
59
92
 
60
- const importSut = async () => import( './ai_sdk.js' );
93
+ const streamResult = {
94
+ textStream: 'TEXT_STREAM',
95
+ fullStream: 'FULL_STREAM'
96
+ };
61
97
 
62
- const basePrompt = {
63
- config: {
64
- provider: 'openai',
65
- model: 'gpt-4o-mini',
66
- temperature: 0.3,
67
- providerOptions: { thinking: { enabled: true } }
98
+ const imageOptions = {
99
+ model: 'IMAGE_MODEL',
100
+ prompt: {
101
+ text: 'Generate an image'
68
102
  },
69
- messages: [ { role: 'user', content: 'Hi' } ]
103
+ providerOptions: { openai: { quality: 'high' } }
70
104
  };
71
105
 
72
- /** Mutable payload from `AI.generateText` — identity checks prove `generateText` returns `wrapTextResponse(...)` without substitution. */
73
- const generateTextAiFixture = {
74
- response: {
75
- text: 'TEXT',
76
- sources: [],
77
- usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
78
- totalUsage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
79
- finishReason: 'stop'
80
- }
106
+ const imageResponse = {
107
+ images: [ { mediaType: 'image/png', base64: 'aW1hZ2U=' } ],
108
+ usage: { inputTokens: 1, outputTokens: 2 }
81
109
  };
82
110
 
83
- beforeEach( () => {
84
- loadModelImpl.mockReset().mockReturnValue( 'MODEL' );
85
- loadPromptImpl.mockReset().mockReturnValue( { ...basePrompt, messages: [ ...basePrompt.messages ] } );
86
- aiFns.tool.mockReset().mockImplementation( def => def );
87
- aiFns.stepCountIs.mockReset().mockImplementation( n => ( { type: 'stepCount', count: n } ) );
88
- loadPromptSkillsImpl.mockReset().mockReturnValue( [] );
89
- loadColocatedSkillsImpl.mockReset().mockReturnValue( [] );
90
-
91
- traceMocks.startTrace.mockReset().mockReturnValue( 'trace-id' );
92
- traceMocks.endTraceWithError.mockReset();
93
-
94
- wrapMocks.wrapTextResponse.mockReset().mockImplementation( async ( { response } ) => response );
95
-
96
- wrapMocks.wrapStreamOnFinishResponse.mockReset().mockImplementation( ( { onFinish } ) => ( {
97
- async onFinish( response ) {
98
- onFinish?.( response );
99
- }
100
- } ) );
101
-
102
- const defaultUsage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
103
- generateTextAiFixture.response = {
104
- text: 'TEXT',
105
- sources: [],
106
- usage: defaultUsage,
107
- totalUsage: defaultUsage,
108
- finishReason: 'stop'
109
- };
110
- aiFns.generateText.mockReset().mockResolvedValue( generateTextAiFixture.response );
111
-
112
- aiFns.streamText.mockReset().mockReturnValue( {
113
- textStream: 'MOCK_TEXT_STREAM',
114
- fullStream: 'MOCK_FULL_STREAM',
115
- text: Promise.resolve( 'STREAMED_TEXT' ),
116
- usage: Promise.resolve( { inputTokens: 10, outputTokens: 5, totalTokens: 15 } ),
117
- finishReason: Promise.resolve( 'stop' ),
118
- sources: Promise.resolve( [] )
119
- } );
120
- } );
121
-
122
- afterEach( async () => {
123
- await vi.resetModules();
124
- vi.clearAllMocks();
125
- } );
126
-
127
111
  describe( 'ai_sdk', () => {
128
- it( 'generateText: validates, delegates trace/wrap utils, calls AI and returns wrapTextResponse output', async () => {
129
- const { generateText } = await importSut();
130
- const defaultUsage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
131
- const result = await generateText( { prompt: 'test_prompt@v1' } );
132
-
133
- expect( validators.validateGenerateTextArgs ).toHaveBeenCalledWith( { prompt: 'test_prompt@v1' } );
134
- expect( loadPromptImpl ).toHaveBeenCalledWith( 'test_prompt@v1', undefined, undefined );
135
- expect( traceMocks.startTrace ).toHaveBeenCalledTimes( 1 );
136
- expect( traceMocks.startTrace ).toHaveBeenCalledWith( expect.objectContaining( {
137
- name: 'generateText',
138
- prompt: 'test_prompt@v1'
139
- } ) );
140
- expect( wrapMocks.wrapTextResponse ).toHaveBeenCalledTimes( 1 );
141
- expect( wrapMocks.wrapTextResponse ).toHaveBeenCalledWith( {
142
- traceId: 'trace-id',
143
- modelId: basePrompt.config.model,
144
- response: generateTextAiFixture.response
112
+ beforeEach( () => {
113
+ aiFns.generateText.mockReset().mockResolvedValue( textResponse );
114
+ aiFns.streamText.mockReset().mockReturnValue( streamResult );
115
+ aiFns.generateImage.mockReset().mockResolvedValue( imageResponse );
116
+ aiFns.stepCountIs.mockReset().mockImplementation( count => ( { type: 'step-count', count } ) );
117
+
118
+ validators.validateGenerateTextArgs.mockReset();
119
+ validators.validateStreamTextArgs.mockReset();
120
+ validators.validateGenerateImageArgs.mockReset();
121
+
122
+ promptMocks.loadPrompt.mockReset().mockReturnValue( loadedPrompt );
123
+ promptMocks.prepareTextPrompt.mockReset().mockReturnValue( {
124
+ loadedPrompt,
125
+ tools: null
145
126
  } );
146
127
 
147
- expect( loadModelImpl ).toHaveBeenCalledWith( basePrompt );
148
- expect( aiFns.generateText ).toHaveBeenCalledWith( {
149
- model: 'MODEL',
150
- messages: basePrompt.messages,
151
- temperature: 0.3,
152
- maxRetries: 0,
153
- providerOptions: basePrompt.config.providerOptions
154
- } );
155
- expect( result ).toBe( generateTextAiFixture.response );
156
- expect( result.totalUsage ).toEqual( defaultUsage );
157
- } );
128
+ optionMocks.loadAiSdkTextOptions.mockReset().mockReturnValue( textOptions );
129
+ optionMocks.loadAiSdkImageOptions.mockReset().mockReturnValue( imageOptions );
158
130
 
159
- it( 'generateText: passes provider-specific options to AI SDK', async () => {
160
- const promptWithProviderOptions = {
161
- config: {
162
- provider: 'anthropic',
163
- model: 'claude-sonnet-4-20250514',
164
- providerOptions: {
165
- thinking: {
166
- type: 'enabled',
167
- budgetTokens: 5000
168
- },
169
- anthropic: {
170
- effort: 'medium',
171
- customOption: 'value'
172
- },
173
- customField: 'should-be-passed'
174
- }
175
- },
176
- messages: [ { role: 'user', content: 'Test' } ]
177
- };
178
- loadPromptImpl.mockReturnValueOnce( promptWithProviderOptions );
179
-
180
- const { generateText } = await importSut();
181
- await generateText( { prompt: 'test_prompt@v1' } );
182
-
183
- expect( aiFns.generateText ).toHaveBeenCalledWith( {
184
- model: 'MODEL',
185
- messages: promptWithProviderOptions.messages,
186
- maxRetries: 0,
187
- providerOptions: {
188
- thinking: {
189
- type: 'enabled',
190
- budgetTokens: 5000
191
- },
192
- anthropic: {
193
- effort: 'medium',
194
- customOption: 'value'
195
- },
196
- customField: 'should-be-passed'
197
- }
198
- } );
199
- } );
131
+ traceMocks.startTrace.mockReset().mockReturnValue( 'trace-id' );
132
+ traceMocks.endTraceWithError.mockReset();
200
133
 
201
- it( 'generateText: passes through providerMetadata', async () => {
202
- const usageProvider = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
203
- aiFns.generateText.mockResolvedValueOnce( {
204
- text: 'TEXT',
205
- sources: [],
206
- usage: usageProvider,
207
- totalUsage: usageProvider,
208
- finishReason: 'stop',
209
- providerMetadata: { anthropic: { cacheReadInputTokens: 50 } }
134
+ wrapMocks.wrapTextResponse.mockReset().mockResolvedValue( { wrapped: textResponse } );
135
+ wrapMocks.wrapStreamOnFinishResponse.mockReset().mockReturnValue( {
136
+ onFinish: vi.fn()
210
137
  } );
211
-
212
- const { generateText } = await importSut();
213
- const result = await generateText( { prompt: 'test_prompt@v1' } );
214
-
215
- expect( result.providerMetadata ).toEqual( { anthropic: { cacheReadInputTokens: 50 } } );
216
- } );
217
-
218
- it( 'generateText: passes through warnings and response metadata', async () => {
219
- const usageWarnings = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
220
- aiFns.generateText.mockResolvedValueOnce( {
221
- text: 'TEXT',
222
- sources: [],
223
- usage: usageWarnings,
224
- totalUsage: usageWarnings,
225
- finishReason: 'stop',
226
- warnings: [ { type: 'other', message: 'Test warning' } ],
227
- response: { id: 'req_123', modelId: 'gpt-4o-2024-05-13' }
138
+ wrapMocks.wrapImageResponse.mockReset().mockResolvedValue( { wrapped: imageResponse } );
139
+
140
+ errorMocks.mapAiError.mockReset().mockImplementation( error => error );
141
+ } );
142
+
143
+ afterEach( async () => {
144
+ await vi.resetModules();
145
+ } );
146
+
147
+ describe( 'generateText', () => {
148
+ it( 'prepares, validates, traces, calls AI SDK, and wraps the response', async () => {
149
+ const { generateText } = await importSut();
150
+ const variables = { topic: 'testing' };
151
+ const tools = { calculator: { description: 'Calculator' } };
152
+ const skills = [ { name: 'style', description: 'Style', instructions: '# Style' } ];
153
+
154
+ promptMocks.prepareTextPrompt.mockReturnValueOnce( {
155
+ loadedPrompt,
156
+ tools
157
+ } );
158
+
159
+ const result = await generateText( {
160
+ prompt: 'test@v1',
161
+ variables,
162
+ promptDir: '/prompts',
163
+ skills,
164
+ maxSteps: 4,
165
+ tools: { userTool: true },
166
+ temperature: 0.2
167
+ } );
168
+
169
+ expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( {
170
+ prompt: 'test@v1',
171
+ variables,
172
+ promptDir: '/prompts',
173
+ skills,
174
+ tools: { userTool: true }
175
+ } );
176
+ expect( validators.validateGenerateTextArgs ).toHaveBeenCalledWith( {
177
+ prompt: 'test@v1',
178
+ variables,
179
+ promptDir: '/prompts',
180
+ skills,
181
+ maxSteps: 4
182
+ } );
183
+ expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
184
+ name: 'generateText',
185
+ prompt: 'test@v1',
186
+ variables,
187
+ loadedPrompt
188
+ } );
189
+ expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( loadedPrompt );
190
+ expect( aiFns.stepCountIs ).toHaveBeenCalledWith( 4 );
191
+ expect( aiFns.generateText ).toHaveBeenCalledWith( {
192
+ ...textOptions,
193
+ maxRetries: 0,
194
+ tools,
195
+ temperature: 0.2,
196
+ stopWhen: { type: 'step-count', count: 4 }
197
+ } );
198
+ expect( wrapMocks.wrapTextResponse ).toHaveBeenCalledWith( {
199
+ traceId: 'trace-id',
200
+ modelId: 'test-model',
201
+ response: textResponse
202
+ } );
203
+ expect( result ).toEqual( { wrapped: textResponse } );
228
204
  } );
229
205
 
230
- const { generateText } = await importSut();
231
- const result = await generateText( { prompt: 'test_prompt@v1' } );
232
-
233
- expect( result.warnings ).toEqual( [ { type: 'other', message: 'Test warning' } ] );
234
- expect( result.response ).toEqual( { id: 'req_123', modelId: 'gpt-4o-2024-05-13' } );
235
- } );
206
+ it( 'uses resolved dynamic skills', async () => {
207
+ const { generateText } = await importSut();
208
+ const variables = { topic: 'testing' };
209
+ const resolvedSkills = [ { name: 'dynamic', description: 'Dynamic', instructions: '# Dynamic' } ];
210
+ const skills = vi.fn().mockResolvedValue( resolvedSkills );
236
211
 
237
- it( 'generateText: traces error and rethrows when AI SDK fails', async () => {
238
- const error = new Error( 'API rate limit exceeded' );
239
- aiFns.generateText.mockRejectedValueOnce( error );
240
- const { generateText } = await importSut();
212
+ await generateText( { prompt: 'test@v1', variables, skills } );
241
213
 
242
- await expect( generateText( { prompt: 'test_prompt@v1' } ) ).rejects.toThrow( 'API rate limit exceeded' );
243
- expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( { traceId: 'trace-id', error } );
244
- } );
245
-
246
- it( 'generateText: passes the AI response object through to wrapTextResponse and returns it', async () => {
247
- const responseWithGetter = {
248
- _internalText: 'TEXT_FROM_GETTER',
249
- get text() {
250
- return this._internalText;
251
- },
252
- sources: [],
253
- usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
254
- totalUsage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
255
- finishReason: 'stop'
256
- };
257
- aiFns.generateText.mockResolvedValueOnce( responseWithGetter );
258
-
259
- const { generateText } = await importSut();
260
- const out = await generateText( { prompt: 'test_prompt@v1' } );
261
-
262
- expect( wrapMocks.wrapTextResponse ).toHaveBeenCalledWith( {
263
- traceId: 'trace-id',
264
- modelId: basePrompt.config.model,
265
- response: responseWithGetter
214
+ expect( skills ).toHaveBeenCalledWith( variables );
215
+ expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( expect.objectContaining( {
216
+ skills: resolvedSkills
217
+ } ) );
266
218
  } );
267
- expect( out ).toBe( responseWithGetter );
268
- expect( out.text ).toBe( 'TEXT_FROM_GETTER' );
269
- } );
270
-
271
- it( 'generateText: passes through AI SDK options like tools and maxRetries', async () => {
272
- const { generateText } = await importSut();
273
- const mockTools = { calculator: { description: 'A calculator tool' } };
274
219
 
275
- await generateText( {
276
- prompt: 'test_prompt@v1',
277
- tools: mockTools,
278
- toolChoice: 'required',
279
- maxRetries: 5,
280
- seed: 42
281
- } );
220
+ it( 'omits tools and stopWhen when no tools are prepared', async () => {
221
+ const { generateText } = await importSut();
282
222
 
283
- expect( aiFns.generateText ).toHaveBeenCalledWith(
284
- expect.objectContaining( {
285
- tools: mockTools,
286
- toolChoice: 'required',
287
- maxRetries: 5,
288
- seed: 42
289
- } )
290
- );
291
- } );
223
+ await generateText( { prompt: 'test@v1' } );
292
224
 
293
- it( 'generateText: user-provided temperature overrides prompt temperature', async () => {
294
- loadPromptImpl.mockReturnValueOnce( {
295
- config: {
296
- provider: 'openai',
297
- model: 'gpt-4o',
298
- temperature: 0.7
299
- },
300
- messages: [ { role: 'user', content: 'Hi' } ]
225
+ expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
226
+ expect( aiFns.generateText ).toHaveBeenCalledWith( {
227
+ ...textOptions,
228
+ maxRetries: 0
229
+ } );
301
230
  } );
302
231
 
303
- const { generateText } = await importSut();
304
- await generateText( { prompt: 'test_prompt@v1', temperature: 0.2 } );
232
+ it( 'preserves caller-provided stopWhen when tools are prepared', async () => {
233
+ const { generateText } = await importSut();
234
+ const stopWhen = { type: 'custom-stop' };
235
+ promptMocks.prepareTextPrompt.mockReturnValueOnce( {
236
+ loadedPrompt,
237
+ tools: { load_skill: { description: 'Load skill' } }
238
+ } );
305
239
 
306
- expect( aiFns.generateText ).toHaveBeenCalledWith(
307
- expect.objectContaining( { temperature: 0.2 } )
308
- );
309
- } );
240
+ await generateText( { prompt: 'test@v1', stopWhen } );
310
241
 
311
- it( 'generateText: passes through temperature: 0 from prompt', async () => {
312
- loadPromptImpl.mockReturnValueOnce( {
313
- config: {
314
- provider: 'openai',
315
- model: 'gpt-4o',
316
- temperature: 0
317
- },
318
- messages: [ { role: 'user', content: 'Hi' } ]
242
+ expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
243
+ expect( aiFns.generateText ).toHaveBeenCalledWith( expect.objectContaining( {
244
+ stopWhen
245
+ } ) );
319
246
  } );
320
247
 
321
- const { generateText } = await importSut();
322
- await generateText( { prompt: 'test_prompt@v1' } );
248
+ it( 'propagates validation errors before tracing or calling AI SDK', async () => {
249
+ const validationError = new Error( 'Invalid args' );
250
+ validators.validateGenerateTextArgs.mockImplementationOnce( () => {
251
+ throw validationError;
252
+ } );
253
+ const { generateText } = await importSut();
323
254
 
324
- expect( aiFns.generateText ).toHaveBeenCalledWith(
325
- expect.objectContaining( { temperature: 0 } )
326
- );
327
- } );
328
-
329
- it( 'generateText: does not add structured output fields the AI response lacks', async () => {
330
- const { generateText } = await importSut();
331
- const result = await generateText( { prompt: 'test_prompt@v1' } );
332
-
333
- expect( result.object ).toBeUndefined();
334
- } );
335
-
336
- it( 'generateText: passes through unknown future options for forward compatibility', async () => {
337
- const { generateText } = await importSut();
338
-
339
- await generateText( {
340
- prompt: 'test_prompt@v1',
341
- experimental_futureOption: { key: 'value' },
342
- unknownOption: true
255
+ await expect( generateText( { prompt: '' } ) ).rejects.toThrow( validationError );
256
+ expect( traceMocks.startTrace ).not.toHaveBeenCalled();
257
+ expect( aiFns.generateText ).not.toHaveBeenCalled();
343
258
  } );
344
259
 
345
- expect( aiFns.generateText ).toHaveBeenCalledWith(
346
- expect.objectContaining( {
347
- experimental_futureOption: { key: 'value' },
348
- unknownOption: true
349
- } )
350
- );
260
+ it( 'traces and rethrows AI SDK errors', async () => {
261
+ const error = new Error( 'Provider failed' );
262
+ const mappedError = new Error( 'Mapped provider failed' );
263
+ aiFns.generateText.mockRejectedValueOnce( error );
264
+ errorMocks.mapAiError.mockReturnValueOnce( mappedError );
265
+ const { generateText } = await importSut();
266
+
267
+ await expect( generateText( { prompt: 'test@v1' } ) ).rejects.toThrow( mappedError );
268
+ expect( errorMocks.mapAiError ).toHaveBeenCalledWith( error );
269
+ expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
270
+ traceId: 'trace-id',
271
+ error: mappedError
272
+ } );
273
+ } );
351
274
  } );
352
275
 
353
- it( 'streamText: validates, delegates trace/wrap utils, calls AI streamText and returns stream result', async () => {
354
- const { streamText } = await importSut();
355
- const result = streamText( { prompt: 'test_prompt@v1' } );
356
-
357
- expect( validators.validateStreamTextArgs ).toHaveBeenCalledWith( { prompt: 'test_prompt@v1' } );
358
- expect( loadPromptImpl ).toHaveBeenCalledWith( 'test_prompt@v1', undefined );
359
- expect( traceMocks.startTrace ).toHaveBeenCalledTimes( 1 );
360
-
361
- expect( loadModelImpl ).toHaveBeenCalledWith( basePrompt );
362
- expect( aiFns.streamText ).toHaveBeenCalledWith(
363
- expect.objectContaining( {
364
- model: 'MODEL',
365
- messages: basePrompt.messages,
366
- temperature: 0.3,
276
+ describe( 'streamText', () => {
277
+ it( 'prepares, validates, traces, calls AI SDK, and returns the stream result', async () => {
278
+ const { streamText } = await importSut();
279
+ const variables = { topic: 'testing' };
280
+ const onFinish = vi.fn();
281
+ const tools = { calculator: { description: 'Calculator' } };
282
+ const skills = [ { name: 'style', description: 'Style', instructions: '# Style' } ];
283
+
284
+ promptMocks.prepareTextPrompt.mockReturnValueOnce( {
285
+ loadedPrompt,
286
+ tools
287
+ } );
288
+
289
+ const result = streamText( {
290
+ prompt: 'test@v1',
291
+ variables,
292
+ promptDir: '/prompts',
293
+ skills,
294
+ maxSteps: 4,
295
+ onFinish,
296
+ tools: { userTool: true },
297
+ temperature: 0.2
298
+ } );
299
+
300
+ expect( validators.validateStreamTextArgs ).toHaveBeenCalledWith( {
301
+ prompt: 'test@v1',
302
+ variables,
303
+ promptDir: '/prompts',
304
+ skills,
305
+ maxSteps: 4
306
+ } );
307
+ expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( {
308
+ prompt: 'test@v1',
309
+ variables,
310
+ promptDir: '/prompts',
311
+ skills,
312
+ tools: { userTool: true }
313
+ } );
314
+ expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
315
+ name: 'streamText',
316
+ prompt: 'test@v1',
317
+ variables,
318
+ loadedPrompt
319
+ } );
320
+ expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( loadedPrompt );
321
+ expect( wrapMocks.wrapStreamOnFinishResponse ).toHaveBeenCalledWith( {
322
+ traceId: 'trace-id',
323
+ modelId: 'test-model',
324
+ onFinish
325
+ } );
326
+ expect( aiFns.stepCountIs ).toHaveBeenCalledWith( 4 );
327
+ expect( aiFns.streamText ).toHaveBeenCalledWith( {
328
+ ...textOptions,
367
329
  maxRetries: 0,
368
- providerOptions: basePrompt.config.providerOptions,
330
+ tools,
331
+ temperature: 0.2,
332
+ stopWhen: { type: 'step-count', count: 4 },
369
333
  onFinish: expect.any( Function ),
370
334
  onError: expect.any( Function )
371
- } )
372
- );
373
- expect( result.textStream ).toBe( 'MOCK_TEXT_STREAM' );
374
- expect( result.fullStream ).toBe( 'MOCK_FULL_STREAM' );
375
- } );
376
-
377
- it( 'streamText: forwards stream onFinish through wrapStreamOnFinishResponse to the user callback', async () => {
378
- const { streamText } = await importSut();
379
- const userOnFinish = vi.fn();
380
-
381
- streamText( { prompt: 'test_prompt@v1', onFinish: userOnFinish } );
382
-
383
- expect( wrapMocks.wrapStreamOnFinishResponse ).toHaveBeenCalledWith( expect.objectContaining( {
384
- traceId: 'trace-id',
385
- modelId: basePrompt.config.model,
386
- onFinish: userOnFinish
387
- } ) );
388
-
389
- const callArgs = aiFns.streamText.mock.calls[0][0];
390
- const usage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
391
- const finishEvent = {
392
- text: 'STREAMED_TEXT',
393
- usage,
394
- totalUsage: usage,
395
- providerMetadata: { anthropic: { cacheReadInputTokens: 50 } },
396
- finishReason: 'stop'
397
- };
398
- await callArgs.onFinish( finishEvent );
399
-
400
- expect( userOnFinish ).toHaveBeenCalledTimes( 1 );
401
- expect( userOnFinish ).toHaveBeenCalledWith( finishEvent );
402
- } );
335
+ } );
336
+ expect( result ).toBe( streamResult );
337
+ } );
403
338
 
404
- it( 'streamText: onError callback traces error and calls user callback', async () => {
405
- const { streamText } = await importSut();
406
- const userOnError = vi.fn();
339
+ it( 'uses resolved dynamic skills', async () => {
340
+ const { streamText } = await importSut();
341
+ const variables = { topic: 'testing' };
342
+ const resolvedSkills = [ { name: 'dynamic', description: 'Dynamic', instructions: '# Dynamic' } ];
343
+ const skills = vi.fn().mockReturnValue( resolvedSkills );
407
344
 
408
- streamText( { prompt: 'test_prompt@v1', onError: userOnError } );
345
+ streamText( { prompt: 'test@v1', variables, skills } );
409
346
 
410
- const callArgs = aiFns.streamText.mock.calls[0][0];
411
- const error = new Error( 'Stream failed' );
412
- callArgs.onError( { error } );
347
+ expect( skills ).toHaveBeenCalledWith( variables );
348
+ expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( expect.objectContaining( {
349
+ skills: resolvedSkills
350
+ } ) );
351
+ } );
413
352
 
414
- expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( { traceId: 'trace-id', error } );
415
- expect( userOnError ).toHaveBeenCalledWith( { error } );
416
- } );
353
+ it( 'throws when dynamic skills resolve asynchronously', async () => {
354
+ const { streamText } = await importSut();
355
+ const variables = { topic: 'testing' };
356
+ const skills = vi.fn().mockResolvedValue( [
357
+ { name: 'dynamic', description: 'Dynamic', instructions: '# Dynamic' }
358
+ ] );
417
359
 
418
- it( 'streamText: works without user onFinish/onError callbacks', async () => {
419
- const { streamText } = await importSut();
420
-
421
- streamText( { prompt: 'test_prompt@v1' } );
422
-
423
- const callArgs = aiFns.streamText.mock.calls[0][0];
424
- const usage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 };
425
- const finishEvent = {
426
- text: 'TEXT',
427
- usage,
428
- totalUsage: usage,
429
- finishReason: 'stop'
430
- };
431
- await expect( callArgs.onFinish( finishEvent ) ).resolves.toBeUndefined();
432
- expect( () => callArgs.onError( { error: new Error( 'fail' ) } ) ).not.toThrow();
433
- } );
360
+ expect( () => streamText( { prompt: 'test@v1', variables, skills } ) )
361
+ .toThrow( 'streamText() skills must be synchronous' );
434
362
 
435
- it( 'streamText: passes through AI SDK streaming options', async () => {
436
- const { streamText } = await importSut();
437
- const mockOnChunk = vi.fn();
438
- const mockOnStepFinish = vi.fn();
439
- const mockTransform = vi.fn();
440
- const mockTools = { calculator: { description: 'A calculator tool' } };
441
-
442
- streamText( {
443
- prompt: 'test_prompt@v1',
444
- tools: mockTools,
445
- toolChoice: 'required',
446
- maxRetries: 5,
447
- onChunk: mockOnChunk,
448
- onStepFinish: mockOnStepFinish,
449
- experimental_transform: mockTransform
363
+ expect( skills ).toHaveBeenCalledWith( variables );
364
+ expect( promptMocks.prepareTextPrompt ).not.toHaveBeenCalled();
365
+ expect( aiFns.streamText ).not.toHaveBeenCalled();
450
366
  } );
451
367
 
452
- expect( aiFns.streamText ).toHaveBeenCalledWith(
453
- expect.objectContaining( {
454
- tools: mockTools,
455
- toolChoice: 'required',
456
- maxRetries: 5,
457
- onChunk: mockOnChunk,
458
- onStepFinish: mockOnStepFinish,
459
- experimental_transform: mockTransform
460
- } )
461
- );
462
- } );
463
-
464
- it( 'streamText: user onFinish/onError are not passed raw to AI SDK', async () => {
465
- const { streamText } = await importSut();
466
- const userOnFinish = vi.fn();
467
- const userOnError = vi.fn();
368
+ it( 'omits tools and stopWhen when no tools are prepared', async () => {
369
+ const { streamText } = await importSut();
468
370
 
469
- streamText( { prompt: 'test_prompt@v1', onFinish: userOnFinish, onError: userOnError } );
371
+ streamText( { prompt: 'test@v1' } );
470
372
 
471
- const callArgs = aiFns.streamText.mock.calls[0][0];
472
- expect( callArgs.onFinish ).not.toBe( userOnFinish );
473
- expect( callArgs.onError ).not.toBe( userOnError );
474
- } );
475
-
476
- it( 'streamText: validation failure propagates synchronously', async () => {
477
- const validationError = new Error( 'prompt is required' );
478
- validators.validateStreamTextArgs.mockImplementationOnce( () => {
479
- throw validationError;
373
+ expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
374
+ expect( aiFns.streamText ).toHaveBeenCalledWith( {
375
+ ...textOptions,
376
+ maxRetries: 0,
377
+ onFinish: expect.any( Function ),
378
+ onError: expect.any( Function )
379
+ } );
480
380
  } );
481
- const { streamText } = await importSut();
482
381
 
483
- expect( () => streamText( { prompt: '' } ) ).toThrow( validationError );
484
- expect( aiFns.streamText ).not.toHaveBeenCalled();
485
- } );
486
-
487
- it( 'streamText: trace start event includes correct name and details', async () => {
488
- const { streamText } = await importSut();
489
- const vars = { topic: 'testing' };
382
+ it( 'preserves caller-provided stopWhen when tools are prepared', async () => {
383
+ const { streamText } = await importSut();
384
+ const stopWhen = { type: 'custom-stop' };
385
+ promptMocks.prepareTextPrompt.mockReturnValueOnce( {
386
+ loadedPrompt,
387
+ tools: { load_skill: { description: 'Load skill' } }
388
+ } );
490
389
 
491
- streamText( { prompt: 'test_prompt@v1', variables: vars } );
390
+ streamText( { prompt: 'test@v1', stopWhen } );
492
391
 
493
- expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
494
- name: 'streamText',
495
- prompt: 'test_prompt@v1',
496
- variables: vars,
497
- loadedPrompt: basePrompt
392
+ expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
393
+ expect( aiFns.streamText ).toHaveBeenCalledWith( expect.objectContaining( {
394
+ stopWhen
395
+ } ) );
498
396
  } );
499
- } );
500
397
 
501
- it( 'streamText: traces error and rethrows when AI.streamText throws synchronously', async () => {
502
- const syncError = new Error( 'Invalid model config' );
503
- aiFns.streamText.mockImplementation( () => {
504
- throw syncError;
398
+ it( 'traces stream onError events and calls the user callback', async () => {
399
+ const { streamText } = await importSut();
400
+ const onError = vi.fn();
401
+ const error = new Error( 'Stream failed' );
402
+ const mappedError = new Error( 'Mapped stream failed' );
403
+ errorMocks.mapAiError.mockReturnValueOnce( mappedError );
404
+
405
+ streamText( { prompt: 'test@v1', onError } );
406
+ const callOptions = aiFns.streamText.mock.calls[0][0];
407
+ callOptions.onError( { error } );
408
+
409
+ expect( errorMocks.mapAiError ).toHaveBeenCalledWith( error );
410
+ expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
411
+ traceId: 'trace-id',
412
+ error: mappedError
413
+ } );
414
+ expect( onError ).toHaveBeenCalledWith( { error: mappedError } );
505
415
  } );
506
- const { streamText } = await importSut();
507
416
 
508
- expect( () => streamText( { prompt: 'test_prompt@v1' } ) ).toThrow( syncError );
509
- expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( { traceId: 'trace-id', error: syncError } );
510
- } );
511
-
512
- it( 'streamText: passes variables to prompt loader', async () => {
513
- const { streamText } = await importSut();
514
- const vars = { name: 'World', count: 5 };
417
+ it( 'does not pass the raw onFinish or onError callbacks to AI SDK', async () => {
418
+ const { streamText } = await importSut();
419
+ const onFinish = vi.fn();
420
+ const onError = vi.fn();
515
421
 
516
- streamText( { prompt: 'test_prompt@v1', variables: vars } );
517
-
518
- expect( loadPromptImpl ).toHaveBeenCalledWith( 'test_prompt@v1', vars );
519
- } );
422
+ streamText( { prompt: 'test@v1', onFinish, onError } );
423
+ const callOptions = aiFns.streamText.mock.calls[0][0];
520
424
 
521
- it( 'generateText: loads frontmatter skills from prompt config using promptFileDir', async () => {
522
- const frontmatterSkill = { name: 'fm_skill', description: 'FM', instructions: '# FM' };
523
- loadPromptImpl.mockReturnValue( {
524
- ...basePrompt,
525
- promptFileDir: '/some/prompt/dir',
526
- config: { ...basePrompt.config, skills: [ './skills/' ] }
425
+ expect( callOptions.onFinish ).not.toBe( onFinish );
426
+ expect( callOptions.onError ).not.toBe( onError );
527
427
  } );
528
- loadPromptSkillsImpl.mockReturnValue( [ frontmatterSkill ] );
529
- const { generateText } = await importSut();
530
- await generateText( { prompt: 'test_prompt@v1' } );
531
428
 
532
- expect( loadPromptSkillsImpl ).toHaveBeenCalledWith( [ './skills/' ], '/some/prompt/dir' );
533
- const callArgs = aiFns.generateText.mock.calls[0][0];
534
- expect( callArgs.tools ).toHaveProperty( 'load_skill' );
535
- } );
536
-
537
- it( 'generateText: merges frontmatter skills with caller-provided skills', async () => {
538
- const frontmatterSkill = { name: 'fm_skill', description: 'FM', instructions: '# FM' };
539
- const callerSkill = { name: 'caller_skill', description: 'Caller', instructions: '# Caller' };
540
- loadPromptImpl.mockReturnValue( {
541
- ...basePrompt,
542
- messages: [ ...basePrompt.messages ],
543
- promptFileDir: '/some/prompt/dir',
544
- config: { ...basePrompt.config, skills: [ './skills/' ] }
429
+ it( 'propagates validation errors before loading or tracing', async () => {
430
+ const validationError = new Error( 'Invalid args' );
431
+ validators.validateStreamTextArgs.mockImplementationOnce( () => {
432
+ throw validationError;
433
+ } );
434
+ const { streamText } = await importSut();
435
+
436
+ expect( () => streamText( { prompt: '' } ) ).toThrow( validationError );
437
+ expect( promptMocks.prepareTextPrompt ).not.toHaveBeenCalled();
438
+ expect( traceMocks.startTrace ).not.toHaveBeenCalled();
439
+ expect( aiFns.streamText ).not.toHaveBeenCalled();
545
440
  } );
546
- loadPromptSkillsImpl.mockReturnValue( [ frontmatterSkill ] );
547
- const { generateText } = await importSut();
548
- await generateText( { prompt: 'test_prompt@v1', skills: [ callerSkill ] } );
549
-
550
- // Skills system message appended, loadPrompt called only once
551
- expect( loadPromptImpl ).toHaveBeenCalledTimes( 1 );
552
- const callArgs = aiFns.generateText.mock.calls[0][0];
553
- const loadSkillResult = callArgs.tools.load_skill.execute( { name: 'caller_skill' } );
554
- expect( loadSkillResult ).toBe( '# Caller' );
555
- } );
556
441
 
557
- it( 'generateText: skips frontmatter skill loading when no config.skills', async () => {
558
- const { generateText } = await importSut();
559
- await generateText( { prompt: 'test_prompt@v1' } );
560
-
561
- expect( loadPromptSkillsImpl ).not.toHaveBeenCalled();
562
- } );
563
-
564
- it( 'generateText: skips frontmatter skill loading when no promptFileDir', async () => {
565
- loadPromptImpl.mockReturnValue( {
566
- ...basePrompt,
567
- config: { ...basePrompt.config, skills: [ './skills/' ] }
568
- // no promptFileDir
442
+ it( 'traces and rethrows synchronous AI SDK errors', async () => {
443
+ const error = new Error( 'Invalid model' );
444
+ const mappedError = new Error( 'Mapped invalid model' );
445
+ aiFns.streamText.mockImplementationOnce( () => {
446
+ throw error;
447
+ } );
448
+ errorMocks.mapAiError.mockReturnValueOnce( mappedError );
449
+ const { streamText } = await importSut();
450
+
451
+ expect( () => streamText( { prompt: 'test@v1' } ) ).toThrow( mappedError );
452
+ expect( errorMocks.mapAiError ).toHaveBeenCalledWith( error );
453
+ expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
454
+ traceId: 'trace-id',
455
+ error: mappedError
456
+ } );
569
457
  } );
570
- const { generateText } = await importSut();
571
- await generateText( { prompt: 'test_prompt@v1' } );
572
-
573
- expect( loadPromptSkillsImpl ).not.toHaveBeenCalled();
574
- const callArgs = aiFns.generateText.mock.calls[0][0];
575
- expect( callArgs.tools ).toBeUndefined();
576
458
  } );
577
459
 
578
- it( 'generateText: appends skills system message when skills present', async () => {
579
- const frontmatterSkill = { name: 'fm_skill', description: 'FM skill', instructions: '# FM' };
580
- loadPromptImpl.mockReturnValue( {
581
- ...basePrompt,
582
- messages: [ ...basePrompt.messages ],
583
- promptFileDir: '/dir',
584
- config: { ...basePrompt.config, skills: [ './skills/' ] }
460
+ describe( 'generateImage', () => {
461
+ it( 'validates, loads prompt, traces, calls AI SDK, and wraps the response', async () => {
462
+ const { generateImage } = await importSut();
463
+ const variables = { scene: 'race cars' };
464
+ const images = [ Buffer.from( 'image-bytes' ) ];
465
+ const mask = Buffer.from( 'mask-bytes' );
466
+
467
+ const result = await generateImage( {
468
+ prompt: 'image@v1',
469
+ variables,
470
+ promptDir: '/prompts',
471
+ images,
472
+ mask,
473
+ n: 2,
474
+ providerOptions: { openai: { background: 'transparent' } }
475
+ } );
476
+
477
+ expect( validators.validateGenerateImageArgs ).toHaveBeenCalledWith( {
478
+ prompt: 'image@v1',
479
+ variables,
480
+ promptDir: '/prompts',
481
+ images,
482
+ mask
483
+ } );
484
+ expect( promptMocks.loadPrompt ).toHaveBeenCalledWith( 'image@v1', variables, '/prompts' );
485
+ expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
486
+ name: 'generateImage',
487
+ prompt: 'image@v1',
488
+ variables,
489
+ loadedPrompt
490
+ } );
491
+ expect( optionMocks.loadAiSdkImageOptions ).toHaveBeenCalledWith( {
492
+ prompt: loadedPrompt,
493
+ images,
494
+ mask
495
+ } );
496
+ expect( aiFns.generateImage ).toHaveBeenCalledWith( {
497
+ ...imageOptions,
498
+ maxRetries: 0,
499
+ n: 2,
500
+ providerOptions: { openai: { background: 'transparent' } }
501
+ } );
502
+ expect( wrapMocks.wrapImageResponse ).toHaveBeenCalledWith( {
503
+ traceId: 'trace-id',
504
+ modelId: 'test-model',
505
+ response: imageResponse
506
+ } );
507
+ expect( result ).toEqual( { wrapped: imageResponse } );
585
508
  } );
586
- loadPromptSkillsImpl.mockReturnValue( [ frontmatterSkill ] );
587
- const { generateText } = await importSut();
588
- await generateText( { prompt: 'test_prompt@v1', variables: { topic: 'AI' } } );
589
-
590
- // Single loadPrompt call — no two-pass render
591
- expect( loadPromptImpl ).toHaveBeenCalledTimes( 1 );
592
- // Skills system message inserted into messages
593
- const callArgs = aiFns.generateText.mock.calls[0][0];
594
- const skillsMsg = callArgs.messages.find( m => m.role === 'system' && m.content.includes( 'fm_skill' ) );
595
- expect( skillsMsg ).toBeDefined();
596
- } );
597
-
598
- it( 'generateText: appends skills message and load_skill tool when skills provided', async () => {
599
- const skills = [
600
- { name: 'research', description: 'Research approach', instructions: '# Research\nDo research' }
601
- ];
602
- loadPromptImpl.mockReturnValue( { ...basePrompt, messages: [ ...basePrompt.messages ] } );
603
- const { generateText } = await importSut();
604
- await generateText( { prompt: 'test_prompt@v1', skills } );
605
-
606
- expect( loadPromptImpl ).toHaveBeenCalledTimes( 1 );
607
- const callArgs = aiFns.generateText.mock.calls[0][0];
608
- expect( callArgs.tools ).toHaveProperty( 'load_skill' );
609
- const skillsMsg = callArgs.messages.find( m => m.role === 'system' && m.content.includes( 'research' ) );
610
- expect( skillsMsg ).toBeDefined();
611
- } );
612
509
 
613
- it( 'generateText: does not inject _system_skills or load_skill when skills is empty', async () => {
614
- const { generateText } = await importSut();
615
- await generateText( { prompt: 'test_prompt@v1', skills: [] } );
616
-
617
- expect( loadPromptImpl ).toHaveBeenCalledWith( 'test_prompt@v1', undefined, undefined );
618
- const callArgs = aiFns.generateText.mock.calls[0][0];
619
- expect( callArgs.tools ).toBeUndefined();
620
- expect( callArgs.stopWhen ).toBeUndefined();
621
- } );
622
-
623
- it( 'generateText: load_skill execute returns instructions for known skill', async () => {
624
- const skills = [
625
- { name: 'research', description: 'Research', instructions: '# Research\nDetailed steps' }
626
- ];
627
- const { generateText } = await importSut();
628
- await generateText( { prompt: 'test_prompt@v1', skills } );
629
-
630
- const { tools } = aiFns.generateText.mock.calls[0][0];
631
- const result = tools.load_skill.execute( { name: 'research' } );
632
- expect( result ).toBe( '# Research\nDetailed steps' );
633
- } );
634
-
635
- it( 'generateText: load_skill execute returns error for unknown skill', async () => {
636
- const skills = [
637
- { name: 'research', description: 'Research', instructions: '# Research' }
638
- ];
639
- const { generateText } = await importSut();
640
- await generateText( { prompt: 'test_prompt@v1', skills } );
641
-
642
- const { tools } = aiFns.generateText.mock.calls[0][0];
643
- const result = tools.load_skill.execute( { name: 'unknown' } );
644
- expect( result ).toMatch( /not found/ );
645
- expect( result ).toContain( 'research' );
646
- } );
647
-
648
- it( 'generateText: sets stopWhen via maxSteps when skills present', async () => {
649
- const skills = [ { name: 'skill', description: 'A skill', instructions: '# Skill' } ];
650
- const { generateText } = await importSut();
651
- await generateText( { prompt: 'test_prompt@v1', skills, maxSteps: 5 } );
652
-
653
- expect( aiFns.generateText ).toHaveBeenCalledWith(
654
- expect.objectContaining( { stopWhen: { type: 'stepCount', count: 5 } } )
655
- );
656
- } );
657
-
658
- it( 'generateText: defaults maxSteps to 10 when skills present', async () => {
659
- const skills = [ { name: 'skill', description: 'A skill', instructions: '# Skill' } ];
660
- const { generateText } = await importSut();
661
- await generateText( { prompt: 'test_prompt@v1', skills } );
662
-
663
- expect( aiFns.generateText ).toHaveBeenCalledWith(
664
- expect.objectContaining( { stopWhen: { type: 'stepCount', count: 10 } } )
665
- );
666
- } );
667
-
668
- it( 'generateText: merges skill tools with user-provided tools', async () => {
669
- const skills = [ { name: 'skill', description: 'A skill', instructions: '# Skill' } ];
670
- const userTools = { calculator: { description: 'A calculator' } };
671
- const { generateText } = await importSut();
672
- await generateText( { prompt: 'test_prompt@v1', skills, tools: userTools } );
673
-
674
- const { tools } = aiFns.generateText.mock.calls[0][0];
675
- expect( tools ).toHaveProperty( 'load_skill' );
676
- expect( tools ).toHaveProperty( 'calculator' );
677
- } );
678
-
679
- it( 'generateText: calls skill function with variables and uses resolved skills', async () => {
680
- const resolvedSkill = { name: 'dynamic', description: 'Dynamic skill', instructions: '# Dynamic' };
681
- const skillsFn = vi.fn().mockResolvedValue( [ resolvedSkill ] );
682
- const vars = { topic: 'AI' };
683
- const { generateText } = await importSut();
684
- await generateText( { prompt: 'test_prompt@v1', variables: vars, skills: skillsFn } );
685
-
686
- expect( skillsFn ).toHaveBeenCalledWith( vars );
687
- expect( loadPromptImpl ).toHaveBeenCalledTimes( 1 );
688
- const callArgs = aiFns.generateText.mock.calls[0][0];
689
- expect( callArgs.tools ).toHaveProperty( 'load_skill' );
690
- const skillsMsg = callArgs.messages.find( m => m.role === 'system' && m.content.includes( 'dynamic' ) );
691
- expect( skillsMsg ).toBeDefined();
692
- } );
510
+ it( 'supports text-to-image calls without images or mask', async () => {
511
+ const { generateImage } = await importSut();
512
+
513
+ await generateImage( { prompt: 'image@v1' } );
514
+
515
+ expect( validators.validateGenerateImageArgs ).toHaveBeenCalledWith( {
516
+ prompt: 'image@v1',
517
+ variables: undefined,
518
+ promptDir: undefined,
519
+ images: undefined,
520
+ mask: undefined
521
+ } );
522
+ expect( optionMocks.loadAiSdkImageOptions ).toHaveBeenCalledWith( {
523
+ prompt: loadedPrompt,
524
+ images: undefined,
525
+ mask: undefined
526
+ } );
527
+ } );
693
528
 
694
- it( 'generateText: preserves caller stopWhen when skills present', async () => {
695
- const skills = [ { name: 'skill', description: 'A skill', instructions: '# Skill' } ];
696
- const customStop = { type: 'custom' };
697
- const { generateText } = await importSut();
698
- await generateText( { prompt: 'test_prompt@v1', skills, stopWhen: customStop } );
529
+ it( 'propagates validation errors before loading or tracing', async () => {
530
+ const validationError = new Error( 'Invalid image args' );
531
+ validators.validateGenerateImageArgs.mockImplementationOnce( () => {
532
+ throw validationError;
533
+ } );
534
+ const { generateImage } = await importSut();
535
+
536
+ await expect( generateImage( { prompt: '' } ) ).rejects.toThrow( validationError );
537
+ expect( promptMocks.loadPrompt ).not.toHaveBeenCalled();
538
+ expect( traceMocks.startTrace ).not.toHaveBeenCalled();
539
+ expect( aiFns.generateImage ).not.toHaveBeenCalled();
540
+ } );
699
541
 
700
- expect( aiFns.generateText ).toHaveBeenCalledWith(
701
- expect.objectContaining( { stopWhen: customStop } )
702
- );
542
+ it( 'traces and rethrows AI SDK errors', async () => {
543
+ const error = new Error( 'Image provider failed' );
544
+ const mappedError = new Error( 'Mapped image provider failed' );
545
+ aiFns.generateImage.mockRejectedValueOnce( error );
546
+ errorMocks.mapAiError.mockReturnValueOnce( mappedError );
547
+ const { generateImage } = await importSut();
548
+
549
+ await expect( generateImage( { prompt: 'image@v1' } ) ).rejects.toThrow( mappedError );
550
+ expect( errorMocks.mapAiError ).toHaveBeenCalledWith( error );
551
+ expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
552
+ traceId: 'trace-id',
553
+ error: mappedError
554
+ } );
555
+ } );
703
556
  } );
704
557
  } );