@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.
- 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/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
|
-
|
|
3
|
+
const coreMocks = vi.hoisted( () => {
|
|
4
|
+
class ValidationError extends Error {}
|
|
5
|
+
return { ValidationError };
|
|
6
|
+
} );
|
|
7
7
|
|
|
8
|
-
const state = vi.hoisted( () => ( {
|
|
8
|
+
const state = vi.hoisted( () => ( {
|
|
9
|
+
invocationDir: '/resolved/invocation'
|
|
10
|
+
} ) );
|
|
9
11
|
|
|
10
|
-
vi.
|
|
11
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
56
|
+
aiMocks.superConstructor( options );
|
|
22
57
|
}
|
|
23
58
|
|
|
24
59
|
async generate( ...args ) {
|
|
25
|
-
return
|
|
60
|
+
return aiMocks.superGenerate( ...args );
|
|
26
61
|
}
|
|
27
62
|
|
|
28
63
|
stream( ...args ) {
|
|
29
|
-
return
|
|
64
|
+
return aiMocks.superStream( ...args );
|
|
30
65
|
}
|
|
31
66
|
}
|
|
67
|
+
|
|
32
68
|
return {
|
|
33
69
|
ToolLoopAgent: MockToolLoopAgent,
|
|
34
|
-
stepCountIs:
|
|
35
|
-
tool: vi.fn( def => def )
|
|
70
|
+
stepCountIs: ( ...args ) => aiMocks.stepCountIs( ...args )
|
|
36
71
|
};
|
|
37
72
|
} );
|
|
38
73
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 ) =>
|
|
50
|
-
endTraceWithError: ( ...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 ) =>
|
|
57
|
-
wrapStreamOnFinishResponse: ( ...args ) =>
|
|
88
|
+
wrapTextResponse: ( ...args ) => wrapMocks.wrapTextResponse( ...args ),
|
|
89
|
+
wrapStreamOnFinishResponse: ( ...args ) => wrapMocks.wrapStreamOnFinishResponse( ...args )
|
|
58
90
|
} ) );
|
|
59
91
|
|
|
60
|
-
vi.mock( './skill.js', () => ( {
|
|
61
|
-
skill:
|
|
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
|
-
|
|
96
|
+
const importSut = async () => import( './agent.js' );
|
|
66
97
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
config: { model: '
|
|
70
|
-
messages:
|
|
71
|
-
|
|
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
|
|
107
|
+
const preparedTools = {
|
|
108
|
+
load_skill: { description: 'Load skill' }
|
|
109
|
+
};
|
|
75
110
|
|
|
76
|
-
|
|
77
|
-
state.promptDir = mkdtempSync( join( tmpdir(), 'agent-test-' ) );
|
|
78
|
-
vi.clearAllMocks();
|
|
111
|
+
const model = { id: 'MODEL' };
|
|
79
112
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
154
|
+
afterEach( async () => {
|
|
155
|
+
await vi.resetModules();
|
|
156
|
+
} );
|
|
96
157
|
|
|
97
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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( '
|
|
170
|
+
it( 'throws when prompt is missing', async () => {
|
|
112
171
|
const { Agent } = await importSut();
|
|
113
|
-
|
|
172
|
+
|
|
173
|
+
expect( () => new Agent( {} ) ).toThrow( coreMocks.ValidationError );
|
|
114
174
|
} );
|
|
115
175
|
|
|
116
|
-
it( '
|
|
176
|
+
it( 'prepares the prompt using the resolved invocation dir', async () => {
|
|
117
177
|
const { Agent } = await importSut();
|
|
118
|
-
|
|
119
|
-
|
|
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( '
|
|
197
|
+
it( 'uses an explicit promptDir when provided', async () => {
|
|
123
198
|
const { Agent } = await importSut();
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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( '
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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( '
|
|
224
|
+
it( 'omits tools when prompt preparation returns null tools', async () => {
|
|
145
225
|
const { Agent } = await importSut();
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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( '
|
|
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
|
-
|
|
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(
|
|
259
|
+
expect( aiMocks.superConstructor ).toHaveBeenCalledWith( expect.objectContaining( {
|
|
260
|
+
temperature: 0.8,
|
|
261
|
+
seed: 42
|
|
262
|
+
} ) );
|
|
185
263
|
} );
|
|
186
|
-
} );
|
|
187
264
|
|
|
188
|
-
|
|
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
|
-
|
|
194
|
-
expect(
|
|
195
|
-
messages:
|
|
270
|
+
|
|
271
|
+
expect( aiMocks.superGenerate ).toHaveBeenCalledWith( {
|
|
272
|
+
messages: [ { role: 'user', content: 'Initial user message' } ]
|
|
196
273
|
} );
|
|
197
274
|
} );
|
|
198
275
|
|
|
199
|
-
it( '
|
|
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( () =>
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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( '
|
|
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
|
-
|
|
230
|
-
{ role: 'assistant', content: '
|
|
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( '
|
|
337
|
+
it( 'streams with initial, stored, and caller messages', async () => {
|
|
235
338
|
const store = {
|
|
236
|
-
getMessages: vi.fn(
|
|
237
|
-
addMessages: vi.fn(
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
response: expect.objectContaining( { text: 'response' } )
|
|
385
|
+
error
|
|
279
386
|
} );
|
|
387
|
+
expect( onError ).toHaveBeenCalledWith( { error } );
|
|
280
388
|
} );
|
|
281
389
|
|
|
282
|
-
it( '
|
|
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(
|
|
288
|
-
expect(
|
|
398
|
+
await expect( agent.stream() ).rejects.toThrow( error );
|
|
399
|
+
expect( traceMocks.endTraceWithError ).toHaveBeenCalledWith( {
|
|
289
400
|
traceId: 'trace-id',
|
|
290
|
-
|
|
291
|
-
onFinish: undefined
|
|
401
|
+
error
|
|
292
402
|
} );
|
|
293
403
|
} );
|
|
294
404
|
} );
|
|
295
405
|
|
|
296
|
-
describe( '
|
|
297
|
-
it( '
|
|
298
|
-
const {
|
|
299
|
-
const
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
await agent.stream();
|
|
411
|
+
store.addMessages( [ { role: 'user', content: 'Hello' } ] );
|
|
412
|
+
store.addMessages( [ { role: 'assistant', content: 'Hi' } ] );
|
|
335
413
|
|
|
336
|
-
expect( store.
|
|
414
|
+
expect( store.getMessages() ).toEqual( [
|
|
415
|
+
{ role: 'user', content: 'Hello' },
|
|
416
|
+
{ role: 'assistant', content: 'Hi' }
|
|
417
|
+
] );
|
|
337
418
|
} );
|
|
338
419
|
} );
|