@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.
- package/package.json +2 -2
- package/src/agent.js +15 -9
- package/src/agent.spec.js +295 -214
- package/src/ai_model.js +79 -36
- package/src/ai_model.spec.js +31 -13
- package/src/ai_sdk.js +55 -79
- package/src/ai_sdk.spec.js +464 -611
- package/src/ai_sdk_options.js +61 -0
- package/src/ai_sdk_options.spec.js +164 -0
- package/src/cost/index.js +1 -1
- package/src/index.d.ts +230 -175
- package/src/index.js +2 -2
- package/src/prompt/escape.js +65 -0
- package/src/prompt/escape.spec.js +159 -0
- package/src/{load_content.js → prompt/load_content.js} +1 -22
- package/src/{load_content.spec.js → prompt/load_content.spec.js} +6 -6
- package/src/prompt/loader.js +49 -0
- package/src/prompt/loader.spec.js +274 -0
- package/src/{prompt_loader_validation.spec.js → prompt/loader_validation.spec.js} +40 -7
- package/src/prompt/parser.js +19 -0
- package/src/{parser.spec.js → prompt/parser.spec.js} +74 -29
- package/src/prompt/prepare_text.js +27 -0
- package/src/prompt/prepare_text.spec.js +141 -0
- package/src/{skill.js → prompt/skill.js} +19 -0
- package/src/prompt/skill.spec.js +172 -0
- package/src/{prompt_validations.js → prompt/validations.js} +32 -6
- package/src/{prompt_validations.spec.js → prompt/validations.spec.js} +189 -1
- package/src/utils/__fixtures__/image_response.json +38 -0
- package/src/utils/__fixtures__/stream_response.json +294 -0
- package/src/utils/__fixtures__/text_response.json +201 -0
- package/src/utils/error_handler.js +65 -0
- package/src/utils/error_handler.spec.js +195 -0
- package/src/utils/image.js +10 -0
- package/src/utils/image.spec.js +20 -0
- package/src/utils/response_wrappers.js +46 -19
- package/src/utils/response_wrappers.spec.js +130 -70
- package/src/utils/source_extraction.js +17 -27
- package/src/utils/trace.js +2 -3
- package/src/utils/trace.spec.js +9 -13
- package/src/validations.js +54 -2
- package/src/validations.spec.js +166 -0
- package/src/parser.js +0 -28
- package/src/prompt_loader.js +0 -80
- package/src/prompt_loader.spec.js +0 -358
- package/src/skill.d.ts +0 -49
package/src/ai_sdk.spec.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
81
|
+
const textOptions = {
|
|
82
|
+
model: 'MODEL',
|
|
83
|
+
messages: loadedPrompt.messages,
|
|
84
|
+
providerOptions: { test: true }
|
|
85
|
+
};
|
|
48
86
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
93
|
+
const streamResult = {
|
|
94
|
+
textStream: 'TEXT_STREAM',
|
|
95
|
+
fullStream: 'FULL_STREAM'
|
|
96
|
+
};
|
|
61
97
|
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
103
|
+
providerOptions: { openai: { quality: 'high' } }
|
|
70
104
|
};
|
|
71
105
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
expect.objectContaining( { temperature: 0.2 } )
|
|
308
|
-
);
|
|
309
|
-
} );
|
|
240
|
+
await generateText( { prompt: 'test@v1', stopWhen } );
|
|
310
241
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
expect
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
345
|
+
streamText( { prompt: 'test@v1', variables, skills } );
|
|
409
346
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
347
|
+
expect( skills ).toHaveBeenCalledWith( variables );
|
|
348
|
+
expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( expect.objectContaining( {
|
|
349
|
+
skills: resolvedSkills
|
|
350
|
+
} ) );
|
|
351
|
+
} );
|
|
413
352
|
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
371
|
+
streamText( { prompt: 'test@v1' } );
|
|
470
372
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
390
|
+
streamText( { prompt: 'test@v1', stopWhen } );
|
|
492
391
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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
|
} );
|