@outputai/llm 0.6.1-dev.daae905.0 → 0.6.1-next.0d08ff5.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
package/src/agent.spec.js CHANGED
@@ -1,338 +1,419 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { mkdtempSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { tmpdir } from 'node:os';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
2
 
6
- // ─── Mocks ────────────────────────────────────────────────────────────────────
3
+ const coreMocks = vi.hoisted( () => {
4
+ class ValidationError extends Error {}
5
+ return { ValidationError };
6
+ } );
7
7
 
8
- const state = vi.hoisted( () => ( { promptDir: '' } ) );
8
+ const state = vi.hoisted( () => ( {
9
+ invocationDir: '/resolved/invocation'
10
+ } ) );
9
11
 
10
- vi.mock( '@outputai/core/sdk_utils', () => ( {
11
- resolveInvocationDir: () => state.promptDir
12
+ const aiMocks = vi.hoisted( () => ( {
13
+ superConstructor: vi.fn(),
14
+ superGenerate: vi.fn(),
15
+ superStream: vi.fn(),
16
+ stepCountIs: vi.fn( count => ( { type: 'step-count', count } ) )
17
+ } ) );
18
+
19
+ const promptMocks = vi.hoisted( () => ( {
20
+ prepareTextPrompt: vi.fn()
21
+ } ) );
22
+
23
+ const optionMocks = vi.hoisted( () => ( {
24
+ loadAiSdkTextOptions: vi.fn()
25
+ } ) );
26
+
27
+ const traceMocks = vi.hoisted( () => ( {
28
+ startTrace: vi.fn(),
29
+ endTraceWithError: vi.fn()
12
30
  } ) );
13
31
 
14
- const superGenerateImpl = vi.fn();
15
- const superStreamImpl = vi.fn();
16
- const superConstructorSpy = vi.fn();
32
+ const wrapMocks = vi.hoisted( () => ( {
33
+ wrapTextResponse: vi.fn(),
34
+ wrapStreamOnFinishResponse: vi.fn()
35
+ } ) );
36
+
37
+ const skillMocks = vi.hoisted( () => ( {
38
+ skill: vi.fn( ( { name, description, instructions } ) => ( {
39
+ name,
40
+ description: description ?? name,
41
+ instructions
42
+ } ) )
43
+ } ) );
44
+
45
+ vi.mock( '@outputai/core', () => ( {
46
+ ValidationError: coreMocks.ValidationError
47
+ } ) );
48
+
49
+ vi.mock( '@outputai/core/sdk_utils', () => ( {
50
+ resolveInvocationDir: () => state.invocationDir
51
+ } ) );
17
52
 
18
53
  vi.mock( 'ai', () => {
19
54
  class MockToolLoopAgent {
20
55
  constructor( options ) {
21
- superConstructorSpy( options );
56
+ aiMocks.superConstructor( options );
22
57
  }
23
58
 
24
59
  async generate( ...args ) {
25
- return superGenerateImpl( ...args );
60
+ return aiMocks.superGenerate( ...args );
26
61
  }
27
62
 
28
63
  stream( ...args ) {
29
- return superStreamImpl( ...args );
64
+ return aiMocks.superStream( ...args );
30
65
  }
31
66
  }
67
+
32
68
  return {
33
69
  ToolLoopAgent: MockToolLoopAgent,
34
- stepCountIs: vi.fn( n => ( { _stepCount: n } ) ),
35
- tool: vi.fn( def => def )
70
+ stepCountIs: ( ...args ) => aiMocks.stepCountIs( ...args )
36
71
  };
37
72
  } );
38
73
 
39
- const hydratePromptTemplateImpl = vi.fn();
40
- const loadAiSdkOptionsImpl = vi.fn();
41
- vi.mock( './ai_sdk.js', () => ( {
42
- hydratePromptTemplate: ( ...args ) => hydratePromptTemplateImpl( ...args ),
43
- loadAiSdkOptionsFromPrompt: ( ...args ) => loadAiSdkOptionsImpl( ...args )
74
+ vi.mock( './prompt/prepare_text.js', () => ( {
75
+ prepareTextPrompt: ( ...args ) => promptMocks.prepareTextPrompt( ...args )
76
+ } ) );
77
+
78
+ vi.mock( './ai_sdk_options.js', () => ( {
79
+ loadAiSdkTextOptions: ( ...args ) => optionMocks.loadAiSdkTextOptions( ...args )
44
80
  } ) );
45
81
 
46
- const startTraceImpl = vi.fn( () => 'trace-id' );
47
- const endTraceWithErrorImpl = vi.fn();
48
82
  vi.mock( './utils/trace.js', () => ( {
49
- startTrace: ( ...args ) => startTraceImpl( ...args ),
50
- endTraceWithError: ( ...args ) => endTraceWithErrorImpl( ...args )
83
+ startTrace: ( ...args ) => traceMocks.startTrace( ...args ),
84
+ endTraceWithError: ( ...args ) => traceMocks.endTraceWithError( ...args )
51
85
  } ) );
52
86
 
53
- const wrapTextResponseImpl = vi.fn( async ( { response } ) => response );
54
- const wrapStreamOnFinishResponseImpl = vi.fn( () => ( {} ) );
55
87
  vi.mock( './utils/response_wrappers.js', () => ( {
56
- wrapTextResponse: ( ...args ) => wrapTextResponseImpl( ...args ),
57
- wrapStreamOnFinishResponse: ( ...args ) => wrapStreamOnFinishResponseImpl( ...args )
88
+ wrapTextResponse: ( ...args ) => wrapMocks.wrapTextResponse( ...args ),
89
+ wrapStreamOnFinishResponse: ( ...args ) => wrapMocks.wrapStreamOnFinishResponse( ...args )
58
90
  } ) );
59
91
 
60
- vi.mock( './skill.js', () => ( {
61
- skill: vi.fn( ( { name, description, instructions } ) => ( { name, description: description ?? name, instructions } ) ),
62
- buildLoadSkillTool: vi.fn( skills => ( { _loadSkillTool: true, skills } ) )
92
+ vi.mock( './prompt/skill.js', () => ( {
93
+ skill: ( ...args ) => skillMocks.skill( ...args )
63
94
  } ) );
64
95
 
65
- // ─── Defaults ─────────────────────────────────────────────────────────────────
96
+ const importSut = async () => import( './agent.js' );
66
97
 
67
- const defaultMessages = [ { role: 'user', content: 'test message' } ];
68
- const defaultPromptMeta = {
69
- config: { model: 'claude-sonnet-4-6' },
70
- messages: defaultMessages,
71
- promptFileDir: '/mock/dir'
98
+ const loadedPrompt = {
99
+ name: 'test@v1',
100
+ config: { model: 'test-model' },
101
+ messages: [
102
+ { role: 'system', content: 'You are concise.' },
103
+ { role: 'user', content: 'Initial user message' }
104
+ ]
72
105
  };
73
106
 
74
- const importSut = () => import( './agent.js' );
107
+ const preparedTools = {
108
+ load_skill: { description: 'Load skill' }
109
+ };
75
110
 
76
- beforeEach( () => {
77
- state.promptDir = mkdtempSync( join( tmpdir(), 'agent-test-' ) );
78
- vi.clearAllMocks();
111
+ const model = { id: 'MODEL' };
79
112
 
80
- hydratePromptTemplateImpl.mockReturnValue( {
81
- loadedPrompt: defaultPromptMeta,
82
- allVariables: {},
83
- tools: {}
84
- } );
85
- loadAiSdkOptionsImpl.mockReturnValue( {
86
- model: { _modelId: 'claude-sonnet-4-6' },
87
- messages: defaultMessages
113
+ const textOptions = {
114
+ model,
115
+ messages: loadedPrompt.messages,
116
+ providerOptions: { test: true },
117
+ temperature: 0.3
118
+ };
119
+
120
+ const aiResponse = {
121
+ text: 'response',
122
+ response: {
123
+ messages: [ { role: 'assistant', content: 'response' } ]
124
+ }
125
+ };
126
+
127
+ describe( 'Agent', () => {
128
+ beforeEach( () => {
129
+ state.invocationDir = '/resolved/invocation';
130
+
131
+ aiMocks.superConstructor.mockReset();
132
+ aiMocks.superGenerate.mockReset().mockResolvedValue( aiResponse );
133
+ aiMocks.superStream.mockReset().mockReturnValue( { textStream: 'stream' } );
134
+ aiMocks.stepCountIs.mockReset().mockImplementation( count => ( { type: 'step-count', count } ) );
135
+
136
+ promptMocks.prepareTextPrompt.mockReset().mockReturnValue( {
137
+ loadedPrompt,
138
+ tools: preparedTools
139
+ } );
140
+
141
+ optionMocks.loadAiSdkTextOptions.mockReset().mockReturnValue( textOptions );
142
+
143
+ traceMocks.startTrace.mockReset().mockReturnValue( 'trace-id' );
144
+ traceMocks.endTraceWithError.mockReset();
145
+
146
+ wrapMocks.wrapTextResponse.mockReset().mockImplementation( async ( { response } ) => response );
147
+ wrapMocks.wrapStreamOnFinishResponse.mockReset().mockReturnValue( {
148
+ onFinish: vi.fn()
149
+ } );
150
+
151
+ skillMocks.skill.mockClear();
88
152
  } );
89
- superGenerateImpl.mockResolvedValue( { text: 'response', response: { messages: [] } } );
90
- superStreamImpl.mockReturnValue( { textStream: 'stream' } );
91
- wrapTextResponseImpl.mockImplementation( async ( { response } ) => response );
92
- wrapStreamOnFinishResponseImpl.mockReturnValue( {} );
93
- } );
94
153
 
95
- // ─── Tests ────────────────────────────────────────────────────────────────────
154
+ afterEach( async () => {
155
+ await vi.resetModules();
156
+ } );
96
157
 
97
- describe( 'skill()', () => {
98
- it( 'creates a skill object with name, description, instructions', async () => {
158
+ it( 're-exports skill()', async () => {
99
159
  const { skill } = await importSut();
100
- const s = skill( { name: 'my_skill', description: 'Does stuff', instructions: '# Do stuff\nStep 1' } );
101
- expect( s ).toEqual( { name: 'my_skill', description: 'Does stuff', instructions: '# Do stuff\nStep 1' } );
102
- } );
103
- } );
104
160
 
105
- describe( 'Agent construction', () => {
106
- it( 'throws ValidationError when prompt is missing', async () => {
107
- const { Agent } = await importSut();
108
- expect( () => new Agent( {} ) ).toThrow( /requires a prompt/ );
161
+ const result = skill( { name: 'writer', instructions: '# Writer' } );
162
+
163
+ expect( result ).toEqual( {
164
+ name: 'writer',
165
+ description: 'writer',
166
+ instructions: '# Writer'
167
+ } );
109
168
  } );
110
169
 
111
- it( 'constructs successfully with a prompt', async () => {
170
+ it( 'throws when prompt is missing', async () => {
112
171
  const { Agent } = await importSut();
113
- expect( () => new Agent( { prompt: 'test@v1' } ) ).not.toThrow();
172
+
173
+ expect( () => new Agent( {} ) ).toThrow( coreMocks.ValidationError );
114
174
  } );
115
175
 
116
- it( 'calls AIToolLoopAgent constructor once at construction time', async () => {
176
+ it( 'prepares the prompt using the resolved invocation dir', async () => {
117
177
  const { Agent } = await importSut();
118
- new Agent( { prompt: 'test@v1' } );
119
- expect( superConstructorSpy ).toHaveBeenCalledTimes( 1 );
178
+ const skills = [ { name: 'style', description: 'Style', instructions: '# Style' } ];
179
+ const tools = { search: { description: 'Search' } };
180
+
181
+ new Agent( {
182
+ prompt: 'test@v1',
183
+ variables: { tone: 'brief' },
184
+ skills,
185
+ tools
186
+ } );
187
+
188
+ expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( {
189
+ prompt: 'test@v1',
190
+ variables: { tone: 'brief' },
191
+ promptDir: state.invocationDir,
192
+ skills,
193
+ tools
194
+ } );
120
195
  } );
121
196
 
122
- it( 'passes model and stopWhen to AIToolLoopAgent constructor', async () => {
197
+ it( 'uses an explicit promptDir when provided', async () => {
123
198
  const { Agent } = await importSut();
124
- new Agent( { prompt: 'test@v1' } );
125
- expect( superConstructorSpy ).toHaveBeenCalledWith( expect.objectContaining( {
126
- model: expect.objectContaining( { _modelId: 'claude-sonnet-4-6' } ),
127
- stopWhen: expect.objectContaining( { _stepCount: 10 } )
199
+
200
+ new Agent( { prompt: 'test@v1', promptDir: '/explicit/prompts' } );
201
+
202
+ expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( expect.objectContaining( {
203
+ promptDir: '/explicit/prompts'
128
204
  } ) );
129
205
  } );
130
206
 
131
- it( 'uses resolveInvocationDir when promptDir not provided', async () => {
207
+ it( 'constructs ToolLoopAgent with text options, instructions, tools, and default stopWhen', async () => {
132
208
  const { Agent } = await importSut();
209
+
133
210
  new Agent( { prompt: 'test@v1' } );
134
- expect( hydratePromptTemplateImpl ).toHaveBeenCalledWith( 'test@v1', {}, state.promptDir, [], {} );
135
- } );
136
211
 
137
- it( 'uses explicitly provided promptDir', async () => {
138
- const explicitDir = mkdtempSync( join( tmpdir(), 'explicit-' ) );
139
- const { Agent } = await importSut();
140
- new Agent( { prompt: 'test@v1', promptDir: explicitDir } );
141
- expect( hydratePromptTemplateImpl ).toHaveBeenCalledWith( 'test@v1', {}, explicitDir, [], {} );
212
+ expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( loadedPrompt );
213
+ expect( aiMocks.stepCountIs ).toHaveBeenCalledWith( 10 );
214
+ expect( aiMocks.superConstructor ).toHaveBeenCalledWith( {
215
+ model,
216
+ providerOptions: { test: true },
217
+ temperature: 0.3,
218
+ instructions: 'You are concise.',
219
+ tools: preparedTools,
220
+ stopWhen: { type: 'step-count', count: 10 }
221
+ } );
142
222
  } );
143
223
 
144
- it( 'passes construction-time variables to hydratePromptTemplate', async () => {
224
+ it( 'omits tools when prompt preparation returns null tools', async () => {
145
225
  const { Agent } = await importSut();
146
- new Agent( { prompt: 'test@v1', variables: { persona: 'writer' } } );
147
- expect( hydratePromptTemplateImpl ).toHaveBeenCalledWith(
148
- 'test@v1', { persona: 'writer' }, state.promptDir, [], {}
149
- );
226
+ promptMocks.prepareTextPrompt.mockReturnValueOnce( {
227
+ loadedPrompt,
228
+ tools: null
229
+ } );
230
+
231
+ new Agent( { prompt: 'test@v1' } );
232
+
233
+ expect( aiMocks.superConstructor ).toHaveBeenCalledWith( {
234
+ model,
235
+ providerOptions: { test: true },
236
+ temperature: 0.3,
237
+ instructions: 'You are concise.',
238
+ stopWhen: { type: 'step-count', count: 10 }
239
+ } );
150
240
  } );
151
- } );
152
241
 
153
- describe( 'Agent.generate() messages', () => {
154
- it( 'passes initialMessages from construction', async () => {
242
+ it( 'uses caller stopWhen instead of default maxSteps', async () => {
155
243
  const { Agent } = await importSut();
156
- const agent = new Agent( { prompt: 'test@v1' } );
157
- await agent.generate();
158
- expect( superGenerateImpl ).toHaveBeenCalledWith( expect.objectContaining( {
159
- messages: defaultMessages
244
+ const stopWhen = { type: 'custom-stop' };
245
+
246
+ new Agent( { prompt: 'test@v1', stopWhen } );
247
+
248
+ expect( aiMocks.stepCountIs ).not.toHaveBeenCalled();
249
+ expect( aiMocks.superConstructor ).toHaveBeenCalledWith( expect.objectContaining( {
250
+ stopWhen
160
251
  } ) );
161
252
  } );
162
253
 
163
- it( 'appends extra messages after initial messages', async () => {
254
+ it( 'passes custom constructor options through', async () => {
164
255
  const { Agent } = await importSut();
165
- const agent = new Agent( { prompt: 'test@v1' } );
166
- const extraMsg = { role: 'assistant', content: 'prior turn' };
167
- await agent.generate( { messages: [ extraMsg ] } );
168
- expect( superGenerateImpl ).toHaveBeenCalledWith( {
169
- messages: [ ...defaultMessages, extraMsg ]
170
- } );
171
- } );
172
- } );
173
256
 
174
- describe( 'Agent.generate() reuse', () => {
175
- it( 'does not call AIToolLoopAgent constructor again on subsequent generate() calls', async () => {
176
- const { Agent } = await importSut();
177
- const agent = new Agent( { prompt: 'test@v1' } );
178
- expect( superConstructorSpy ).toHaveBeenCalledTimes( 1 );
179
-
180
- await agent.generate();
181
- await agent.generate();
182
- await agent.generate();
257
+ new Agent( { prompt: 'test@v1', temperature: 0.8, seed: 42 } );
183
258
 
184
- expect( superConstructorSpy ).toHaveBeenCalledTimes( 1 );
259
+ expect( aiMocks.superConstructor ).toHaveBeenCalledWith( expect.objectContaining( {
260
+ temperature: 0.8,
261
+ seed: 42
262
+ } ) );
185
263
  } );
186
- } );
187
264
 
188
- describe( 'Agent.generate() conversation store', () => {
189
- it( 'does not use store when none provided (stateless)', async () => {
265
+ it( 'keeps only user prompt messages as initial generate messages', async () => {
190
266
  const { Agent } = await importSut();
191
267
  const agent = new Agent( { prompt: 'test@v1' } );
268
+
192
269
  await agent.generate();
193
- // No error, no store interaction — just prompt messages
194
- expect( superGenerateImpl ).toHaveBeenCalledWith( {
195
- messages: defaultMessages
270
+
271
+ expect( aiMocks.superGenerate ).toHaveBeenCalledWith( {
272
+ messages: [ { role: 'user', content: 'Initial user message' } ]
196
273
  } );
197
274
  } );
198
275
 
199
- it( 'loads prior messages from store before calling super.generate', async () => {
200
- const priorMessages = [ { role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' } ];
276
+ it( 'combines initial, stored, and caller messages for generate', async () => {
201
277
  const store = {
202
- getMessages: vi.fn( () => priorMessages ),
278
+ getMessages: vi.fn( () => [ { role: 'assistant', content: 'Stored reply' } ] ),
203
279
  addMessages: vi.fn()
204
280
  };
205
-
281
+ const callerMessage = { role: 'user', content: 'New question' };
206
282
  const { Agent } = await importSut();
207
283
  const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
208
- await agent.generate( { messages: [ { role: 'user', content: 'new msg' } ] } );
209
284
 
210
- expect( store.getMessages ).toHaveBeenCalled();
211
- expect( superGenerateImpl ).toHaveBeenCalledWith( {
212
- messages: [ ...defaultMessages, ...priorMessages, { role: 'user', content: 'new msg' } ]
285
+ await agent.generate( { messages: [ callerMessage ], maxRetries: 1 } );
286
+
287
+ expect( aiMocks.superGenerate ).toHaveBeenCalledWith( {
288
+ messages: [
289
+ { role: 'user', content: 'Initial user message' },
290
+ { role: 'assistant', content: 'Stored reply' },
291
+ callerMessage
292
+ ],
293
+ maxRetries: 1
213
294
  } );
214
295
  } );
215
296
 
216
- it( 'appends user messages and response messages to store after generate()', async () => {
217
- const responseMessages = [ { role: 'assistant', content: 'reply' } ];
218
- superGenerateImpl.mockResolvedValue( { text: 'reply', response: { messages: responseMessages } } );
297
+ it( 'wraps generate responses and stores the user and response messages', async () => {
219
298
  const store = {
220
299
  getMessages: vi.fn( () => [] ),
221
300
  addMessages: vi.fn()
222
301
  };
223
-
302
+ const callerMessage = { role: 'user', content: 'New question' };
224
303
  const { Agent } = await importSut();
225
304
  const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
226
- await agent.generate( { messages: [ { role: 'user', content: 'ask' } ] } );
227
305
 
306
+ const result = await agent.generate( { messages: [ callerMessage ] } );
307
+
308
+ expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
309
+ name: 'Agent.generate',
310
+ prompt: 'test@v1'
311
+ } );
312
+ expect( wrapMocks.wrapTextResponse ).toHaveBeenCalledWith( {
313
+ traceId: 'trace-id',
314
+ modelId: 'test-model',
315
+ response: aiResponse
316
+ } );
228
317
  expect( store.addMessages ).toHaveBeenCalledWith( [
229
- { role: 'user', content: 'ask' },
230
- { role: 'assistant', content: 'reply' }
318
+ callerMessage,
319
+ { role: 'assistant', content: 'response' }
231
320
  ] );
321
+ expect( result ).toBe( aiResponse );
322
+ } );
323
+
324
+ it( 'traces and rethrows generate errors', async () => {
325
+ const error = new Error( 'Generate failed' );
326
+ aiMocks.superGenerate.mockRejectedValueOnce( error );
327
+ const { Agent } = await importSut();
328
+ const agent = new Agent( { prompt: 'test@v1' } );
329
+
330
+ await expect( agent.generate() ).rejects.toThrow( error );
331
+ expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
332
+ traceId: 'trace-id',
333
+ error
334
+ } );
232
335
  } );
233
336
 
234
- it( 'supports async store methods', async () => {
337
+ it( 'streams with initial, stored, and caller messages', async () => {
235
338
  const store = {
236
- getMessages: vi.fn( async () => [] ),
237
- addMessages: vi.fn( async () => {} )
339
+ getMessages: vi.fn( () => [ { role: 'assistant', content: 'Stored reply' } ] ),
340
+ addMessages: vi.fn()
238
341
  };
239
-
342
+ const onFinish = vi.fn();
343
+ const onError = vi.fn();
344
+ const callerMessage = { role: 'user', content: 'New question' };
240
345
  const { Agent } = await importSut();
241
346
  const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
242
- await agent.generate();
243
347
 
244
- expect( store.getMessages ).toHaveBeenCalled();
245
- expect( store.addMessages ).toHaveBeenCalled();
246
- } );
247
- } );
248
-
249
- describe( 'createMemoryConversationStore()', () => {
250
- it( 'starts with empty messages', async () => {
251
- const { createMemoryConversationStore } = await importSut();
252
- const store = createMemoryConversationStore();
253
- expect( store.getMessages() ).toEqual( [] );
254
- } );
348
+ const result = await agent.stream( { messages: [ callerMessage ], onFinish, onError, maxRetries: 1 } );
255
349
 
256
- it( 'accumulates messages across addMessages calls', async () => {
257
- const { createMemoryConversationStore } = await importSut();
258
- const store = createMemoryConversationStore();
259
- store.addMessages( [ { role: 'user', content: 'hi' } ] );
260
- store.addMessages( [ { role: 'assistant', content: 'hello' } ] );
261
- expect( store.getMessages() ).toEqual( [
262
- { role: 'user', content: 'hi' },
263
- { role: 'assistant', content: 'hello' }
264
- ] );
350
+ expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
351
+ name: 'Agent.stream',
352
+ prompt: 'test@v1'
353
+ } );
354
+ expect( wrapMocks.wrapStreamOnFinishResponse ).toHaveBeenCalledWith( {
355
+ traceId: 'trace-id',
356
+ modelId: 'test-model',
357
+ onFinish
358
+ } );
359
+ expect( aiMocks.superStream ).toHaveBeenCalledWith( {
360
+ messages: [
361
+ { role: 'user', content: 'Initial user message' },
362
+ { role: 'assistant', content: 'Stored reply' },
363
+ callerMessage
364
+ ],
365
+ maxRetries: 1,
366
+ onFinish: expect.any( Function ),
367
+ onError: expect.any( Function )
368
+ } );
369
+ expect( result ).toEqual( { textStream: 'stream' } );
370
+ expect( store.addMessages ).not.toHaveBeenCalled();
265
371
  } );
266
- } );
267
372
 
268
- describe( 'Agent utils delegation', () => {
269
- it( 'generate() calls trace and wrapTextResponse with model id and response', async () => {
373
+ it( 'traces stream onError events and calls the user callback', async () => {
374
+ const onError = vi.fn();
375
+ const error = new Error( 'Stream failed' );
270
376
  const { Agent } = await importSut();
271
377
  const agent = new Agent( { prompt: 'test@v1' } );
272
- await agent.generate( { messages: [ { role: 'user', content: 'hi' } ] } );
273
378
 
274
- expect( startTraceImpl ).toHaveBeenCalledWith( { name: 'Agent.generate', prompt: 'test@v1' } );
275
- expect( wrapTextResponseImpl ).toHaveBeenCalledWith( {
379
+ await agent.stream( { onError } );
380
+ const streamOptions = aiMocks.superStream.mock.calls[0][0];
381
+ streamOptions.onError( { error } );
382
+
383
+ expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
276
384
  traceId: 'trace-id',
277
- modelId: 'claude-sonnet-4-6',
278
- response: expect.objectContaining( { text: 'response' } )
385
+ error
279
386
  } );
387
+ expect( onError ).toHaveBeenCalledWith( { error } );
280
388
  } );
281
389
 
282
- it( 'stream() calls trace and wrapStreamOnFinishResponse', async () => {
390
+ it( 'traces and rethrows stream errors', async () => {
391
+ const error = new Error( 'Stream failed' );
392
+ aiMocks.superStream.mockImplementationOnce( () => {
393
+ throw error;
394
+ } );
283
395
  const { Agent } = await importSut();
284
396
  const agent = new Agent( { prompt: 'test@v1' } );
285
- await agent.stream();
286
397
 
287
- expect( startTraceImpl ).toHaveBeenCalledWith( { name: 'Agent.stream', prompt: 'test@v1' } );
288
- expect( wrapStreamOnFinishResponseImpl ).toHaveBeenCalledWith( {
398
+ await expect( agent.stream() ).rejects.toThrow( error );
399
+ expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
289
400
  traceId: 'trace-id',
290
- modelId: 'claude-sonnet-4-6',
291
- onFinish: undefined
401
+ error
292
402
  } );
293
403
  } );
294
404
  } );
295
405
 
296
- describe( 'Agent.stream()', () => {
297
- it( 'uses pre-rendered messages when no variables provided', async () => {
298
- const { Agent } = await importSut();
299
- const agent = new Agent( { prompt: 'test@v1' } );
300
- await agent.stream();
301
- expect( superStreamImpl ).toHaveBeenCalledWith(
302
- expect.objectContaining( {
303
- messages: defaultMessages
304
- } )
305
- );
306
- } );
307
-
308
- it( 'loads prior messages from store', async () => {
309
- const priorMessages = [ { role: 'user', content: 'old' } ];
310
- const store = {
311
- getMessages: vi.fn( () => priorMessages ),
312
- addMessages: vi.fn()
313
- };
314
-
315
- const { Agent } = await importSut();
316
- const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
317
- await agent.stream( { messages: [ { role: 'user', content: 'new' } ] } );
318
-
319
- expect( superStreamImpl ).toHaveBeenCalledWith(
320
- expect.objectContaining( {
321
- messages: [ ...defaultMessages, ...priorMessages, { role: 'user', content: 'new' } ]
322
- } )
323
- );
324
- } );
325
-
326
- it( 'does not auto-append to store', async () => {
327
- const store = {
328
- getMessages: vi.fn( () => [] ),
329
- addMessages: vi.fn()
330
- };
406
+ describe( 'createMemoryConversationStore', () => {
407
+ it( 'stores messages in memory', async () => {
408
+ const { createMemoryConversationStore } = await importSut();
409
+ const store = createMemoryConversationStore();
331
410
 
332
- const { Agent } = await importSut();
333
- const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
334
- await agent.stream();
411
+ store.addMessages( [ { role: 'user', content: 'Hello' } ] );
412
+ store.addMessages( [ { role: 'assistant', content: 'Hi' } ] );
335
413
 
336
- expect( store.addMessages ).not.toHaveBeenCalled();
414
+ expect( store.getMessages() ).toEqual( [
415
+ { role: 'user', content: 'Hello' },
416
+ { role: 'assistant', content: 'Hi' }
417
+ ] );
337
418
  } );
338
419
  } );