@outputai/llm 0.11.0 → 0.11.1-next.2223fa5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +2 -2
  2. package/src/agent.js +105 -83
  3. package/src/agent.spec.js +312 -225
  4. package/src/ai_provider.js +2 -2
  5. package/src/ai_sdk_options.js +93 -39
  6. package/src/ai_sdk_options.spec.js +314 -154
  7. package/src/consts.js +6 -0
  8. package/src/generate.js +81 -0
  9. package/src/generate.spec.js +466 -0
  10. package/src/index.d.ts +222 -242
  11. package/src/index.js +3 -4
  12. package/src/prompt/content.js +70 -0
  13. package/src/prompt/content.spec.js +208 -0
  14. package/src/prompt/{escape.js → interpolations.js} +11 -18
  15. package/src/prompt/interpolations.spec.js +109 -0
  16. package/src/prompt/loader.full.spec.js +293 -0
  17. package/src/prompt/loader.js +45 -39
  18. package/src/prompt/loader.spec.js +186 -289
  19. package/src/prompt/markup/attributes.js +52 -0
  20. package/src/prompt/markup/attributes.spec.js +132 -0
  21. package/src/prompt/markup/nodes.js +71 -0
  22. package/src/prompt/markup/nodes.spec.js +333 -0
  23. package/src/prompt/markup/tokenizer.js +3 -0
  24. package/src/prompt/markup/tokenizer.spec.js +149 -0
  25. package/src/prompt/markup/tokens.js +26 -0
  26. package/src/prompt/markup/tokens.spec.js +250 -0
  27. package/src/prompt/validations.js +98 -67
  28. package/src/prompt/validations.spec.js +204 -47
  29. package/src/{prompt/load_content.js → utils/file.js} +12 -15
  30. package/src/utils/file.spec.js +89 -0
  31. package/src/utils/models.js +15 -0
  32. package/src/utils/models.spec.js +119 -0
  33. package/src/utils/skills.js +74 -0
  34. package/src/utils/skills.spec.js +168 -0
  35. package/src/utils/sources.js +48 -0
  36. package/src/utils/sources.spec.js +122 -0
  37. package/src/utils/stream.js +22 -0
  38. package/src/utils/stream.spec.js +55 -0
  39. package/src/utils/tools.js +47 -0
  40. package/src/utils/tools.spec.js +167 -0
  41. package/src/utils/wrap.js +148 -0
  42. package/src/utils/wrap.spec.js +359 -0
  43. package/src/validations.js +149 -27
  44. package/src/validations.spec.js +451 -53
  45. package/src/ai_model.js +0 -60
  46. package/src/ai_model.spec.js +0 -259
  47. package/src/ai_sdk.js +0 -92
  48. package/src/ai_sdk.spec.js +0 -564
  49. package/src/prompt/block_options.js +0 -58
  50. package/src/prompt/block_options.spec.js +0 -71
  51. package/src/prompt/blocks.js +0 -47
  52. package/src/prompt/blocks.spec.js +0 -63
  53. package/src/prompt/escape.spec.js +0 -159
  54. package/src/prompt/load_content.spec.js +0 -83
  55. package/src/prompt/loader_validation.spec.js +0 -128
  56. package/src/prompt/parser.js +0 -16
  57. package/src/prompt/parser.spec.js +0 -186
  58. package/src/prompt/prepare_text.js +0 -27
  59. package/src/prompt/prepare_text.spec.js +0 -141
  60. package/src/prompt/skill.js +0 -128
  61. package/src/prompt/skill.spec.js +0 -172
  62. package/src/utils/message.js +0 -3
  63. package/src/utils/message.spec.js +0 -29
  64. package/src/utils/response_wrappers.js +0 -100
  65. package/src/utils/response_wrappers.spec.js +0 -240
  66. package/src/utils/source_extraction.js +0 -53
  67. package/src/utils/source_extraction.spec.js +0 -194
  68. package/src/utils/trace.js +0 -19
  69. package/src/utils/trace.spec.js +0 -112
package/src/agent.spec.js CHANGED
@@ -1,55 +1,52 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
- const coreMocks = vi.hoisted( () => {
4
- class ValidationError extends Error {}
5
- return { ValidationError };
6
- } );
7
-
8
- const state = vi.hoisted( () => ( {
9
- invocationDir: '/resolved/invocation'
10
- } ) );
11
-
12
3
  const aiMocks = vi.hoisted( () => ( {
13
4
  superConstructor: vi.fn(),
14
5
  superGenerate: vi.fn(),
15
- superStream: vi.fn(),
16
- stepCountIs: vi.fn( count => ( { type: 'step-count', count } ) )
6
+ superStream: vi.fn()
17
7
  } ) );
18
8
 
19
- const promptMocks = vi.hoisted( () => ( {
20
- prepareTextPrompt: vi.fn()
9
+ const validations = vi.hoisted( () => ( {
10
+ parseAgentArgs: vi.fn(),
11
+ parseAgentGenerateArgs: vi.fn(),
12
+ parseAgentGenerateWithStreamingArgs: vi.fn(),
13
+ parseAgentStreamArgs: vi.fn()
21
14
  } ) );
22
15
 
23
- const optionMocks = vi.hoisted( () => ( {
24
- loadAiSdkTextOptions: vi.fn()
16
+ const toPromptFileArgs = ( { prompt, ...rest } ) => ( {
17
+ promptFile: prompt,
18
+ ...rest
19
+ } );
20
+
21
+ const toAgentMethodArgs = ( args = {} ) => ( {
22
+ messages: [],
23
+ ...args
24
+ } );
25
+
26
+ const promptMocks = vi.hoisted( () => ( {
27
+ loadPrompt: vi.fn()
25
28
  } ) );
26
29
 
27
- const traceMocks = vi.hoisted( () => ( {
28
- startTrace: vi.fn(),
29
- endTraceWithError: vi.fn()
30
+ const skillMocks = vi.hoisted( () => ( {
31
+ loadSkills: vi.fn()
30
32
  } ) );
31
33
 
32
- const wrapMocks = vi.hoisted( () => ( {
33
- wrapTextResponse: vi.fn(),
34
- wrapStreamOnFinishResponse: vi.fn()
34
+ const optionMocks = vi.hoisted( () => ( {
35
+ loadAiSdkTextOptions: vi.fn()
35
36
  } ) );
36
37
 
37
- const skillMocks = vi.hoisted( () => ( {
38
- skill: vi.fn( ( { name, description, instructions } ) => ( {
39
- name,
40
- description: description ?? name,
41
- instructions
42
- } ) )
38
+ const wrapMocks = vi.hoisted( () => ( {
39
+ wrapGeneration: vi.fn(),
40
+ wrapStream: vi.fn(),
41
+ streamHooks: { onFinishHook: vi.fn(), onErrorHook: vi.fn() }
43
42
  } ) );
44
43
 
45
- vi.mock( '@outputai/core', () => ( {
46
- ValidationError: coreMocks.ValidationError
44
+ const streamMocks = vi.hoisted( () => ( {
45
+ drainStream: vi.fn()
47
46
  } ) );
48
47
 
49
- vi.mock( '@outputai/core/sdk/helpers', () => ( {
50
- Path: {
51
- resolveInvocationDir: () => state.invocationDir
52
- }
48
+ const loggerMocks = vi.hoisted( () => ( {
49
+ error: vi.fn()
53
50
  } ) );
54
51
 
55
52
  vi.mock( 'ai', () => {
@@ -67,49 +64,50 @@ vi.mock( 'ai', () => {
67
64
  }
68
65
  }
69
66
 
70
- return {
71
- ToolLoopAgent: MockToolLoopAgent,
72
- stepCountIs: ( ...args ) => aiMocks.stepCountIs( ...args )
73
- };
67
+ return { ToolLoopAgent: MockToolLoopAgent };
74
68
  } );
75
69
 
76
- vi.mock( './prompt/prepare_text.js', () => ( {
77
- prepareTextPrompt: ( ...args ) => promptMocks.prepareTextPrompt( ...args )
70
+ vi.mock( './validations.js', () => validations );
71
+
72
+ vi.mock( '@outputai/core', () => ( {
73
+ Logger: {
74
+ error: ( ...args ) => loggerMocks.error( ...args )
75
+ }
76
+ } ) );
77
+
78
+ vi.mock( './prompt/loader.js', () => ( {
79
+ loadPrompt: ( ...args ) => promptMocks.loadPrompt( ...args )
78
80
  } ) );
79
81
 
80
- vi.mock( './ai_sdk_options.js', () => ( {
81
- loadAiSdkTextOptions: ( ...args ) => optionMocks.loadAiSdkTextOptions( ...args )
82
+ vi.mock( './utils/skills.js', () => ( {
83
+ loadSkills: ( ...args ) => skillMocks.loadSkills( ...args )
82
84
  } ) );
83
85
 
84
- vi.mock( './utils/trace.js', () => ( {
85
- startTrace: ( ...args ) => traceMocks.startTrace( ...args ),
86
- endTraceWithError: ( ...args ) => traceMocks.endTraceWithError( ...args )
86
+ vi.mock( './ai_sdk_options.js', () => ( {
87
+ loadAiSdkTextOptions: ( ...args ) => optionMocks.loadAiSdkTextOptions( ...args )
87
88
  } ) );
88
89
 
89
- vi.mock( './utils/response_wrappers.js', () => ( {
90
- wrapTextResponse: ( ...args ) => wrapMocks.wrapTextResponse( ...args ),
91
- wrapStreamOnFinishResponse: ( ...args ) =>
92
- wrapMocks.wrapStreamOnFinishResponse( ...args )
90
+ vi.mock( './utils/wrap.js', () => ( {
91
+ wrapGeneration: ( ...args ) => wrapMocks.wrapGeneration( ...args ),
92
+ wrapStream: ( ...args ) => wrapMocks.wrapStream( ...args )
93
93
  } ) );
94
94
 
95
- vi.mock( './prompt/skill.js', () => ( {
96
- skill: ( ...args ) => skillMocks.skill( ...args )
95
+ vi.mock( './utils/stream.js', () => ( {
96
+ drainStream: ( ...args ) => streamMocks.drainStream( ...args )
97
97
  } ) );
98
98
 
99
99
  const importSut = async () => import( './agent.js' );
100
100
 
101
101
  const loadedPrompt = {
102
102
  name: 'test@v1',
103
- config: { provider: 'openai', model: 'test-model' },
103
+ config: { provider: 'openai', model: 'test-model', maxSteps: 10 },
104
104
  messages: [
105
105
  { role: 'system', content: 'You are concise.' },
106
106
  { role: 'user', content: 'Initial user message' }
107
107
  ]
108
108
  };
109
109
 
110
- const preparedTools = {
111
- load_skill: { description: 'Load skill' }
112
- };
110
+ const loadedSkills = [ { name: 'writer', description: 'Writes', instructions: 'Do it.' } ];
113
111
 
114
112
  const model = { id: 'MODEL' };
115
113
 
@@ -121,118 +119,116 @@ const textOptions = {
121
119
  temperature: 0.3
122
120
  };
123
121
 
122
+ const assistantMessage = { role: 'assistant', content: 'response' };
123
+
124
124
  const aiResponse = {
125
125
  text: 'response',
126
- response: {
127
- messages: [ { role: 'assistant', content: 'response' } ]
128
- }
126
+ finishReason: 'stop',
127
+ response: { messages: [ assistantMessage ] }
129
128
  };
130
129
 
131
130
  describe( 'Agent', () => {
132
131
  beforeEach( () => {
133
- state.invocationDir = '/resolved/invocation';
134
-
135
132
  aiMocks.superConstructor.mockReset();
136
133
  aiMocks.superGenerate.mockReset().mockResolvedValue( aiResponse );
137
134
  aiMocks.superStream.mockReset().mockReturnValue( { textStream: 'stream' } );
138
- aiMocks.stepCountIs
139
- .mockReset()
140
- .mockImplementation( count => ( { type: 'step-count', count } ) );
141
135
 
142
- promptMocks.prepareTextPrompt.mockReset().mockReturnValue( {
143
- loadedPrompt,
144
- tools: preparedTools
145
- } );
136
+ validations.parseAgentArgs.mockReset().mockImplementation( toPromptFileArgs );
137
+ validations.parseAgentGenerateArgs.mockReset().mockImplementation( toAgentMethodArgs );
138
+ validations.parseAgentGenerateWithStreamingArgs.mockReset().mockImplementation( toAgentMethodArgs );
139
+ validations.parseAgentStreamArgs.mockReset().mockImplementation( toAgentMethodArgs );
146
140
 
147
- optionMocks.loadAiSdkTextOptions.mockReset().mockReturnValue( textOptions );
141
+ promptMocks.loadPrompt.mockReset().mockReturnValue( loadedPrompt );
142
+ skillMocks.loadSkills.mockReset().mockReturnValue( loadedSkills );
148
143
 
149
- traceMocks.startTrace.mockReset().mockReturnValue( 'trace-id' );
150
- traceMocks.endTraceWithError.mockReset();
151
-
152
- wrapMocks.wrapTextResponse
153
- .mockReset()
154
- .mockImplementation( async ( { response } ) => response );
155
- wrapMocks.wrapStreamOnFinishResponse.mockReset().mockReturnValue( {
156
- onFinish: vi.fn()
157
- } );
144
+ optionMocks.loadAiSdkTextOptions.mockReset().mockReturnValue( textOptions );
158
145
 
159
- skillMocks.skill.mockClear();
146
+ wrapMocks.wrapGeneration.mockReset().mockImplementation( async ( { fn } ) => fn() );
147
+ wrapMocks.streamHooks = {
148
+ onFinishHook: vi.fn( async ( response, callback ) => callback?.( response ) ),
149
+ onErrorHook: vi.fn( ( event, callback ) => callback?.( event.error ) )
150
+ };
151
+ wrapMocks.wrapStream.mockReset().mockImplementation( ( { fn } ) => fn( wrapMocks.streamHooks ) );
152
+ streamMocks.drainStream.mockReset().mockResolvedValue( undefined );
153
+ loggerMocks.error.mockReset();
160
154
  } );
161
155
 
162
156
  afterEach( async () => {
163
157
  await vi.resetModules();
164
158
  } );
165
159
 
166
- it( 're-exports skill()', async () => {
167
- const { skill } = await importSut();
168
-
169
- const result = skill( { name: 'writer', instructions: '# Writer' } );
170
-
171
- expect( result ).toEqual( {
172
- name: 'writer',
173
- description: 'writer',
174
- instructions: '# Writer'
160
+ it( 'propagates parse errors before loading the prompt', async () => {
161
+ const validationError = new Error( 'Invalid Agent() arguments' );
162
+ validations.parseAgentArgs.mockImplementationOnce( () => {
163
+ throw validationError;
175
164
  } );
176
- } );
177
-
178
- it( 'throws when prompt is missing', async () => {
179
165
  const { Agent } = await importSut();
180
166
 
181
- expect( () => new Agent( {} ) ).toThrow( coreMocks.ValidationError );
167
+ expect( () => new Agent( {} ) ).toThrow( validationError );
168
+ expect( promptMocks.loadPrompt ).not.toHaveBeenCalled();
169
+ expect( skillMocks.loadSkills ).not.toHaveBeenCalled();
170
+ expect( aiMocks.superConstructor ).not.toHaveBeenCalled();
182
171
  } );
183
172
 
184
- it( 'prepares the prompt using the resolved invocation dir', async () => {
173
+ it( 'parses args, loads prompt skills, and constructs ToolLoopAgent', async () => {
185
174
  const { Agent } = await importSut();
186
- const skills = [
187
- { name: 'style', description: 'Style', instructions: '# Style' }
188
- ];
189
175
  const tools = { search: { description: 'Search' } };
176
+ const output = { type: 'object' };
177
+ const stopWhen = { type: 'custom-stop' };
178
+ const messageStore = { getMessages() {}, addMessages() {} };
190
179
 
191
180
  new Agent( {
192
181
  prompt: 'test@v1',
193
182
  variables: { tone: 'brief' },
194
- skills,
195
- tools
183
+ promptDir: '/prompts',
184
+ tools,
185
+ output,
186
+ stopWhen,
187
+ messageStore
196
188
  } );
197
189
 
198
- expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( {
190
+ expect( validations.parseAgentArgs ).toHaveBeenCalledWith( {
199
191
  prompt: 'test@v1',
200
192
  variables: { tone: 'brief' },
201
- promptDir: state.invocationDir,
202
- skills,
203
- tools
193
+ promptDir: '/prompts',
194
+ tools,
195
+ output,
196
+ stopWhen,
197
+ messageStore
198
+ } );
199
+ expect( promptMocks.loadPrompt ).toHaveBeenCalledWith( 'test@v1', { tone: 'brief' }, '/prompts' );
200
+ expect( skillMocks.loadSkills ).toHaveBeenCalledWith( loadedPrompt );
201
+ expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( {
202
+ prompt: loadedPrompt,
203
+ skills: loadedSkills,
204
+ tools,
205
+ output,
206
+ stopWhen
207
+ } );
208
+ expect( aiMocks.superConstructor ).toHaveBeenCalledWith( {
209
+ model,
210
+ providerOptions: { test: true },
211
+ temperature: 0.3,
212
+ instructions: [ { role: 'system', content: 'You are concise.' } ]
204
213
  } );
205
214
  } );
206
215
 
207
- it( 'uses an explicit promptDir when provided', async () => {
216
+ it( 'passes option tools through to ToolLoopAgent', async () => {
208
217
  const { Agent } = await importSut();
218
+ optionMocks.loadAiSdkTextOptions.mockReturnValueOnce( {
219
+ ...textOptions,
220
+ tools: { load_skill: { description: 'Load skill' } }
221
+ } );
209
222
 
210
- new Agent( { prompt: 'test@v1', promptDir: '/explicit/prompts' } );
223
+ new Agent( { prompt: 'test@v1' } );
211
224
 
212
- expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith(
225
+ expect( aiMocks.superConstructor ).toHaveBeenCalledWith(
213
226
  expect.objectContaining( {
214
- promptDir: '/explicit/prompts'
227
+ tools: { load_skill: { description: 'Load skill' } }
215
228
  } )
216
229
  );
217
230
  } );
218
231
 
219
- it( 'constructs ToolLoopAgent with text options, instructions, tools, and default stopWhen', async () => {
220
- const { Agent } = await importSut();
221
-
222
- new Agent( { prompt: 'test@v1' } );
223
-
224
- expect( optionMocks.loadAiSdkTextOptions ).toHaveBeenCalledWith( loadedPrompt );
225
- expect( aiMocks.stepCountIs ).toHaveBeenCalledWith( 10 );
226
- expect( aiMocks.superConstructor ).toHaveBeenCalledWith( {
227
- model,
228
- providerOptions: { test: true },
229
- temperature: 0.3,
230
- instructions: [ { role: 'system', content: 'You are concise.' } ],
231
- tools: preparedTools,
232
- stopWhen: { type: 'step-count', count: 10 }
233
- } );
234
- } );
235
-
236
232
  it( 'preserves per-message providerOptions on system messages passed as instructions', async () => {
237
233
  const { Agent } = await importSut();
238
234
  const systemMessage = {
@@ -255,48 +251,18 @@ describe( 'Agent', () => {
255
251
  );
256
252
  } );
257
253
 
258
- it( 'omits tools when prompt preparation returns null tools', async () => {
254
+ it( 'omits instructions when there is no system message', async () => {
259
255
  const { Agent } = await importSut();
260
- promptMocks.prepareTextPrompt.mockReturnValueOnce( {
261
- loadedPrompt,
262
- tools: null
263
- } );
264
-
265
- new Agent( { prompt: 'test@v1' } );
266
-
267
- expect( aiMocks.superConstructor ).toHaveBeenCalledWith( {
256
+ optionMocks.loadAiSdkTextOptions.mockReturnValueOnce( {
268
257
  model,
269
- providerOptions: { test: true },
270
- temperature: 0.3,
271
- instructions: [ { role: 'system', content: 'You are concise.' } ],
272
- stopWhen: { type: 'step-count', count: 10 }
258
+ system: [],
259
+ messages: [ { role: 'user', content: 'Hello' } ]
273
260
  } );
274
- } );
275
-
276
- it( 'uses caller stopWhen instead of default maxSteps', async () => {
277
- const { Agent } = await importSut();
278
- const stopWhen = { type: 'custom-stop' };
279
-
280
- new Agent( { prompt: 'test@v1', stopWhen } );
281
-
282
- expect( aiMocks.stepCountIs ).not.toHaveBeenCalled();
283
- expect( aiMocks.superConstructor ).toHaveBeenCalledWith(
284
- expect.objectContaining( {
285
- stopWhen
286
- } )
287
- );
288
- } );
289
261
 
290
- it( 'passes custom constructor options through', async () => {
291
- const { Agent } = await importSut();
292
-
293
- new Agent( { prompt: 'test@v1', temperature: 0.8, seed: 42 } );
262
+ new Agent( { prompt: 'test@v1' } );
294
263
 
295
264
  expect( aiMocks.superConstructor ).toHaveBeenCalledWith(
296
- expect.objectContaining( {
297
- temperature: 0.8,
298
- seed: 42
299
- } )
265
+ expect.not.objectContaining( { instructions: expect.anything() } )
300
266
  );
301
267
  } );
302
268
 
@@ -306,13 +272,19 @@ describe( 'Agent', () => {
306
272
 
307
273
  await agent.generate();
308
274
 
275
+ expect( validations.parseAgentGenerateArgs ).toHaveBeenCalledWith( undefined );
276
+ expect( wrapMocks.wrapGeneration ).toHaveBeenCalledWith( {
277
+ name: 'Agent.generate',
278
+ prompt: loadedPrompt,
279
+ fn: expect.any( Function )
280
+ } );
309
281
  expect( aiMocks.superGenerate ).toHaveBeenCalledWith( {
310
282
  messages: [ { role: 'user', content: 'Initial user message' } ],
311
283
  allowSystemInMessages: true
312
284
  } );
313
285
  } );
314
286
 
315
- it( 'excludes authored assistant/tool blocks from the initial generate messages', async () => {
287
+ it( 'excludes authored assistant blocks from the initial generate messages', async () => {
316
288
  const { Agent } = await importSut();
317
289
  optionMocks.loadAiSdkTextOptions.mockReturnValueOnce( {
318
290
  model,
@@ -332,7 +304,7 @@ describe( 'Agent', () => {
332
304
  } );
333
305
  } );
334
306
 
335
- it( 'combines initial, stored, and caller messages for generate', async () => {
307
+ it( 'combines initial, stored, and caller messages and forwards generate options', async () => {
336
308
  const store = {
337
309
  getMessages: vi.fn( () => [
338
310
  { role: 'assistant', content: 'Stored reply' }
@@ -340,11 +312,21 @@ describe( 'Agent', () => {
340
312
  addMessages: vi.fn()
341
313
  };
342
314
  const callerMessage = { role: 'user', content: 'New question' };
315
+ const abortSignal = AbortSignal.abort();
343
316
  const { Agent } = await importSut();
344
- const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
317
+ const agent = new Agent( { prompt: 'test@v1', messageStore: store } );
345
318
 
346
- await agent.generate( { messages: [ callerMessage ], maxRetries: 1 } );
319
+ await agent.generate( {
320
+ messages: [ callerMessage ],
321
+ abortSignal,
322
+ toolChoice: 'required'
323
+ } );
347
324
 
325
+ expect( validations.parseAgentGenerateArgs ).toHaveBeenCalledWith( {
326
+ messages: [ callerMessage ],
327
+ abortSignal,
328
+ toolChoice: 'required'
329
+ } );
348
330
  expect( aiMocks.superGenerate ).toHaveBeenCalledWith( {
349
331
  messages: [
350
332
  { role: 'user', content: 'Initial user message' },
@@ -352,7 +334,8 @@ describe( 'Agent', () => {
352
334
  callerMessage
353
335
  ],
354
336
  allowSystemInMessages: true,
355
- maxRetries: 1
337
+ abortSignal,
338
+ toolChoice: 'required'
356
339
  } );
357
340
  } );
358
341
 
@@ -363,41 +346,114 @@ describe( 'Agent', () => {
363
346
  };
364
347
  const callerMessage = { role: 'user', content: 'New question' };
365
348
  const { Agent } = await importSut();
366
- const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
349
+ const agent = new Agent( { prompt: 'test@v1', messageStore: store } );
367
350
 
368
351
  const result = await agent.generate( { messages: [ callerMessage ] } );
369
352
 
370
- expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
353
+ expect( wrapMocks.wrapGeneration ).toHaveBeenCalledWith( {
371
354
  name: 'Agent.generate',
372
- prompt: 'test@v1'
373
- } );
374
- expect( wrapMocks.wrapTextResponse ).toHaveBeenCalledWith( {
375
- traceId: 'trace-id',
376
- providerId: 'openai',
377
- modelId: 'test-model',
378
- response: aiResponse
355
+ prompt: loadedPrompt,
356
+ fn: expect.any( Function )
379
357
  } );
380
358
  expect( store.addMessages ).toHaveBeenCalledWith( [
381
359
  callerMessage,
382
- { role: 'assistant', content: 'response' }
360
+ assistantMessage
383
361
  ] );
384
362
  expect( result ).toBe( aiResponse );
385
363
  } );
386
364
 
387
- it( 'traces and rethrows generate errors', async () => {
388
- const error = new Error( 'Generate failed' );
389
- aiMocks.superGenerate.mockRejectedValueOnce( error );
365
+ it( 'generates with streaming, stores messages, and returns the completed response', async () => {
366
+ const store = {
367
+ getMessages: vi.fn( () => [] ),
368
+ addMessages: vi.fn()
369
+ };
370
+ const callerMessage = { role: 'user', content: 'New question' };
371
+ const output = { summary: 'Structured result' };
372
+ const chunk = { type: 'text-delta', text: 'response' };
373
+ const onChunk = vi.fn();
374
+ const abortSignal = AbortSignal.abort();
375
+ const stream = { output: Promise.resolve( output ) };
376
+ const response = { ...aiResponse };
377
+ aiMocks.superStream.mockImplementationOnce( options => {
378
+ options.onChunk( { chunk } );
379
+ options.onFinish( response );
380
+ return stream;
381
+ } );
390
382
  const { Agent } = await importSut();
391
- const agent = new Agent( { prompt: 'test@v1' } );
383
+ const agent = new Agent( { prompt: 'test@v1', messageStore: store } );
384
+
385
+ const result = await agent.generateWithStreaming( {
386
+ messages: [ callerMessage ],
387
+ onChunk,
388
+ abortSignal,
389
+ toolChoice: 'required'
390
+ } );
392
391
 
393
- await expect( agent.generate() ).rejects.toThrow( error );
394
- expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
395
- traceId: 'trace-id',
396
- error
392
+ expect( validations.parseAgentGenerateWithStreamingArgs ).toHaveBeenCalledWith( {
393
+ messages: [ callerMessage ],
394
+ onChunk,
395
+ abortSignal,
396
+ toolChoice: 'required'
397
+ } );
398
+ expect( wrapMocks.wrapGeneration ).toHaveBeenCalledWith( {
399
+ name: 'Agent.generateWithStreaming',
400
+ prompt: loadedPrompt,
401
+ fn: expect.any( Function )
397
402
  } );
403
+ expect( aiMocks.superStream ).toHaveBeenCalledWith( {
404
+ messages: [
405
+ { role: 'user', content: 'Initial user message' },
406
+ callerMessage
407
+ ],
408
+ allowSystemInMessages: true,
409
+ onChunk,
410
+ abortSignal,
411
+ toolChoice: 'required',
412
+ onFinish: expect.any( Function ),
413
+ onError: expect.any( Function )
414
+ } );
415
+ expect( streamMocks.drainStream ).toHaveBeenCalledWith( stream, abortSignal );
416
+ expect( store.addMessages ).toHaveBeenCalledWith( [
417
+ callerMessage,
418
+ assistantMessage
419
+ ] );
420
+ expect( onChunk ).toHaveBeenCalledWith( { chunk } );
421
+ expect( result ).toEqual( { ...aiResponse, output } );
422
+ } );
423
+
424
+ it( 'omits onChunk when generateWithStreaming does not receive it', async () => {
425
+ const stream = { output: Promise.resolve( undefined ) };
426
+ aiMocks.superStream.mockImplementationOnce( options => {
427
+ options.onFinish( { ...aiResponse } );
428
+ return stream;
429
+ } );
430
+ const { Agent } = await importSut();
431
+ const agent = new Agent( { prompt: 'test@v1' } );
432
+
433
+ await agent.generateWithStreaming();
434
+ const callOptions = aiMocks.superStream.mock.calls[0][0];
435
+
436
+ expect( callOptions ).not.toHaveProperty( 'onChunk' );
398
437
  } );
399
438
 
400
- it( 'streams with initial, stored, and caller messages', async () => {
439
+ it( 'rejects generateWithStreaming with the abort reason', async () => {
440
+ const abortController = new AbortController();
441
+ const abortReason = new Error( 'Cancelled by caller' );
442
+ const stream = { output: Promise.resolve( undefined ) };
443
+ abortController.abort( abortReason );
444
+ streamMocks.drainStream.mockRejectedValueOnce( abortReason );
445
+ aiMocks.superStream.mockReturnValueOnce( stream );
446
+ const { Agent } = await importSut();
447
+ const agent = new Agent( { prompt: 'test@v1' } );
448
+
449
+ await expect( agent.generateWithStreaming( {
450
+ abortSignal: abortController.signal
451
+ } ) ).rejects.toBe( abortReason );
452
+
453
+ expect( streamMocks.drainStream ).toHaveBeenCalledWith( stream, abortController.signal );
454
+ } );
455
+
456
+ it( 'streams with initial, stored, and caller messages and stores on finish', async () => {
401
457
  const store = {
402
458
  getMessages: vi.fn( () => [
403
459
  { role: 'assistant', content: 'Stored reply' }
@@ -406,26 +462,28 @@ describe( 'Agent', () => {
406
462
  };
407
463
  const onFinish = vi.fn();
408
464
  const onError = vi.fn();
465
+ const onChunk = vi.fn();
409
466
  const callerMessage = { role: 'user', content: 'New question' };
410
467
  const { Agent } = await importSut();
411
- const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
468
+ const agent = new Agent( { prompt: 'test@v1', messageStore: store } );
412
469
 
413
470
  const result = await agent.stream( {
414
471
  messages: [ callerMessage ],
415
472
  onFinish,
416
473
  onError,
417
- maxRetries: 1
474
+ onChunk
418
475
  } );
419
476
 
420
- expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
421
- name: 'Agent.stream',
422
- prompt: 'test@v1'
477
+ expect( validations.parseAgentStreamArgs ).toHaveBeenCalledWith( {
478
+ messages: [ callerMessage ],
479
+ onFinish,
480
+ onError,
481
+ onChunk
423
482
  } );
424
- expect( wrapMocks.wrapStreamOnFinishResponse ).toHaveBeenCalledWith( {
425
- traceId: 'trace-id',
426
- providerId: 'openai',
427
- modelId: 'test-model',
428
- onFinish
483
+ expect( wrapMocks.wrapStream ).toHaveBeenCalledWith( {
484
+ name: 'Agent.stream',
485
+ prompt: loadedPrompt,
486
+ fn: expect.any( Function )
429
487
  } );
430
488
  expect( aiMocks.superStream ).toHaveBeenCalledWith( {
431
489
  messages: [
@@ -434,58 +492,87 @@ describe( 'Agent', () => {
434
492
  callerMessage
435
493
  ],
436
494
  allowSystemInMessages: true,
437
- maxRetries: 1,
495
+ onChunk,
438
496
  onFinish: expect.any( Function ),
439
497
  onError: expect.any( Function )
440
498
  } );
499
+ const streamOptions = aiMocks.superStream.mock.calls[0][0];
500
+ await streamOptions.onFinish( aiResponse );
501
+ expect( wrapMocks.streamHooks.onFinishHook ).toHaveBeenCalledWith( aiResponse, expect.any( Function ) );
502
+ expect( store.addMessages ).toHaveBeenCalledWith( [
503
+ callerMessage,
504
+ assistantMessage
505
+ ] );
506
+ expect( onFinish ).toHaveBeenCalledWith( aiResponse );
441
507
  expect( result ).toEqual( { textStream: 'stream' } );
442
- expect( store.addMessages ).not.toHaveBeenCalled();
443
508
  } );
444
509
 
445
- it( 'traces stream onError events and calls the user callback', async () => {
446
- const onError = vi.fn();
447
- const error = new Error( 'Stream failed' );
510
+ it( 'logs stream persistence failures and still completes onFinish', async () => {
511
+ const persistenceError = new Error( 'Store unavailable' );
512
+ const store = {
513
+ getMessages: vi.fn( () => [] ),
514
+ addMessages: vi.fn().mockRejectedValue( persistenceError )
515
+ };
516
+ const onFinish = vi.fn();
448
517
  const { Agent } = await importSut();
449
- const agent = new Agent( { prompt: 'test@v1' } );
518
+ const agent = new Agent( { prompt: 'test@v1', messageStore: store } );
450
519
 
451
- await agent.stream( { onError } );
520
+ const result = await agent.stream( { onFinish } );
452
521
  const streamOptions = aiMocks.superStream.mock.calls[0][0];
453
- streamOptions.onError( { error } );
454
522
 
455
- expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
456
- traceId: 'trace-id',
457
- error
458
- } );
459
- expect( onError ).toHaveBeenCalledWith( { error } );
523
+ await expect( streamOptions.onFinish( aiResponse ) ).resolves.toBeUndefined();
524
+
525
+ expect( loggerMocks.error ).toHaveBeenCalledWith(
526
+ 'Agent.stream message store persistence failed',
527
+ { namespace: 'LLM', error: persistenceError.message }
528
+ );
529
+ expect( onFinish ).toHaveBeenCalledWith( aiResponse );
530
+ expect( result ).toEqual( { textStream: 'stream' } );
460
531
  } );
461
532
 
462
- it( 'traces and rethrows stream errors', async () => {
463
- const error = new Error( 'Stream failed' );
464
- aiMocks.superStream.mockImplementationOnce( () => {
465
- throw error;
466
- } );
533
+ it( 'does not store stream messages when finishReason is error', async () => {
534
+ const store = {
535
+ getMessages: vi.fn( () => [] ),
536
+ addMessages: vi.fn()
537
+ };
538
+ const onFinish = vi.fn();
539
+ const { Agent } = await importSut();
540
+ const agent = new Agent( { prompt: 'test@v1', messageStore: store } );
541
+
542
+ await agent.stream( { onFinish } );
543
+ const streamOptions = aiMocks.superStream.mock.calls[0][0];
544
+ await streamOptions.onFinish( { ...aiResponse, finishReason: 'error' } );
545
+
546
+ expect( store.addMessages ).not.toHaveBeenCalled();
547
+ expect( onFinish ).toHaveBeenCalledWith( { ...aiResponse, finishReason: 'error' } );
548
+ } );
549
+
550
+ it( 'omits onChunk when stream does not receive it', async () => {
467
551
  const { Agent } = await importSut();
468
552
  const agent = new Agent( { prompt: 'test@v1' } );
469
553
 
470
- await expect( agent.stream() ).rejects.toThrow( error );
471
- expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
472
- traceId: 'trace-id',
473
- error
474
- } );
554
+ await agent.stream();
555
+ const callOptions = aiMocks.superStream.mock.calls[0][0];
556
+
557
+ expect( callOptions ).not.toHaveProperty( 'onChunk' );
475
558
  } );
476
- } );
477
559
 
478
- describe( 'createMemoryConversationStore', () => {
479
- it( 'stores messages in memory', async () => {
480
- const { createMemoryConversationStore } = await importSut();
481
- const store = createMemoryConversationStore();
560
+ it( 'forwards stream onError through wrapStream with the mapped event payload', async () => {
561
+ const onError = vi.fn();
562
+ const error = new Error( 'Stream failed' );
563
+ const mappedError = new Error( 'Mapped stream failed' );
564
+ const { Agent } = await importSut();
565
+ const agent = new Agent( { prompt: 'test@v1' } );
482
566
 
483
- store.addMessages( [ { role: 'user', content: 'Hello' } ] );
484
- store.addMessages( [ { role: 'assistant', content: 'Hi' } ] );
567
+ await agent.stream( { onError } );
568
+ const streamOptions = aiMocks.superStream.mock.calls[0][0];
569
+ streamOptions.onError( { error, extra: true } );
485
570
 
486
- expect( store.getMessages() ).toEqual( [
487
- { role: 'user', content: 'Hello' },
488
- { role: 'assistant', content: 'Hi' }
489
- ] );
571
+ expect( wrapMocks.streamHooks.onErrorHook ).toHaveBeenCalledWith(
572
+ { error, extra: true },
573
+ expect.any( Function )
574
+ );
575
+ wrapMocks.streamHooks.onErrorHook.mock.calls[0][1]( mappedError );
576
+ expect( onError ).toHaveBeenCalledWith( { error: mappedError, extra: true } );
490
577
  } );
491
578
  } );