@lobehub/chat 1.128.7 → 1.128.9
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/.cursor/rules/debug-usage.mdc +2 -2
- package/.github/workflows/sync-database-schema.yml +7 -2
- package/.github/workflows/test.yml +1 -0
- package/CHANGELOG.md +51 -0
- package/apps/desktop/package.json +1 -1
- package/changelog/v1.json +18 -0
- package/docs/development/database-schema.dbml +2 -0
- package/package.json +3 -1
- package/packages/agent-runtime/examples/tools-calling.ts +303 -0
- package/packages/agent-runtime/package.json +15 -0
- package/packages/agent-runtime/src/core/__tests__/runtime.test.ts +1046 -0
- package/packages/agent-runtime/src/core/index.ts +1 -0
- package/packages/agent-runtime/src/core/runtime.ts +656 -0
- package/packages/agent-runtime/src/index.ts +2 -0
- package/packages/agent-runtime/src/types/event.ts +121 -0
- package/packages/agent-runtime/src/types/index.ts +5 -0
- package/packages/agent-runtime/src/types/instruction.ts +125 -0
- package/packages/agent-runtime/src/types/runtime.ts +18 -0
- package/packages/agent-runtime/src/types/state.ts +108 -0
- package/packages/agent-runtime/src/types/usage.ts +128 -0
- package/packages/agent-runtime/vitest.config.mts +12 -0
- package/packages/database/migrations/0031_add_agent_index.sql +2 -0
- package/packages/database/migrations/meta/0031_snapshot.json +6447 -0
- package/packages/database/migrations/meta/_journal.json +7 -0
- package/packages/database/src/core/migrations.json +9 -0
- package/packages/database/src/models/session.ts +30 -27
- package/packages/database/src/schemas/agent.ts +3 -0
- package/packages/file-loaders/src/loadFile.ts +1 -0
- package/packages/file-loaders/src/loaders/docx/index.ts +6 -1
- package/packages/model-runtime/src/core/openaiCompatibleFactory/index.test.ts +72 -0
- package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +34 -2
- package/packages/model-runtime/src/core/streams/anthropic.ts +7 -2
- package/packages/model-runtime/src/core/streams/google-ai.ts +7 -2
- package/packages/model-runtime/src/core/streams/openai/openai.ts +15 -2
- package/packages/model-runtime/src/core/streams/openai/responsesStream.ts +14 -2
- package/packages/model-runtime/src/core/streams/protocol.ts +8 -2
- package/packages/model-runtime/src/core/streams/qwen.ts +10 -2
- package/packages/model-runtime/src/core/streams/spark.test.ts +2 -2
- package/packages/model-runtime/src/core/streams/spark.ts +10 -1
- package/packages/model-runtime/src/core/streams/vertex-ai.ts +8 -2
- package/packages/model-runtime/src/providers/azureOpenai/index.ts +6 -3
- package/packages/model-runtime/src/providers/azureai/index.ts +6 -3
- package/src/features/ChatInput/ActionBar/History/Controls.tsx +12 -2
- package/src/features/ChatInput/ActionBar/History/index.tsx +18 -1
- package/src/features/ChatInput/ActionBar/Search/index.tsx +19 -1
- package/src/features/ChatInput/ActionBar/components/Action.tsx +5 -1
- package/src/services/session/server.test.ts +4 -1
- package/src/services/session/server.ts +7 -1
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Agent,
|
|
5
|
+
AgentEventError,
|
|
6
|
+
AgentState,
|
|
7
|
+
Cost,
|
|
8
|
+
CostCalculationContext,
|
|
9
|
+
CostLimit,
|
|
10
|
+
RuntimeConfig,
|
|
11
|
+
RuntimeContext,
|
|
12
|
+
ToolsCalling,
|
|
13
|
+
Usage,
|
|
14
|
+
} from '../../types';
|
|
15
|
+
import { AgentRuntime } from '../runtime';
|
|
16
|
+
|
|
17
|
+
// Mock Agent for testing
|
|
18
|
+
class MockAgent implements Agent {
|
|
19
|
+
tools = {};
|
|
20
|
+
executors = {};
|
|
21
|
+
modelRuntime?: (payload: unknown) => AsyncIterable<any>;
|
|
22
|
+
|
|
23
|
+
async runner(context: RuntimeContext, state: AgentState) {
|
|
24
|
+
switch (context.phase) {
|
|
25
|
+
case 'user_input':
|
|
26
|
+
return { type: 'call_llm' as const, payload: { messages: state.messages } };
|
|
27
|
+
case 'llm_result':
|
|
28
|
+
const llmPayload = context.payload as { result: any; hasToolCalls: boolean };
|
|
29
|
+
if (llmPayload.hasToolCalls) {
|
|
30
|
+
return {
|
|
31
|
+
type: 'request_human_approve' as const,
|
|
32
|
+
pendingToolsCalling: llmPayload.result.tool_calls,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { type: 'finish' as const, reason: 'completed' as const, reasonDetail: 'Done' };
|
|
36
|
+
case 'tool_result':
|
|
37
|
+
return { type: 'call_llm' as const, payload: { messages: state.messages } };
|
|
38
|
+
default:
|
|
39
|
+
return { type: 'finish' as const, reason: 'completed' as const, reasonDetail: 'Done' };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Helper function to create test context
|
|
45
|
+
function createTestContext(
|
|
46
|
+
phase: RuntimeContext['phase'],
|
|
47
|
+
payload?: any,
|
|
48
|
+
sessionId: string = 'test-session',
|
|
49
|
+
): RuntimeContext {
|
|
50
|
+
return {
|
|
51
|
+
phase,
|
|
52
|
+
payload,
|
|
53
|
+
session: {
|
|
54
|
+
sessionId,
|
|
55
|
+
messageCount: 1,
|
|
56
|
+
eventCount: 0,
|
|
57
|
+
status: 'idle',
|
|
58
|
+
stepCount: 0,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('AgentRuntime', () => {
|
|
64
|
+
describe('Constructor and Executor Priority', () => {
|
|
65
|
+
it('should use built-in executors by default', () => {
|
|
66
|
+
const agent = new MockAgent();
|
|
67
|
+
const runtime = new AgentRuntime(agent);
|
|
68
|
+
|
|
69
|
+
// @ts-expect-error - accessing private property for testing
|
|
70
|
+
const executors = runtime.executors;
|
|
71
|
+
|
|
72
|
+
expect(executors).toHaveProperty('call_llm');
|
|
73
|
+
expect(executors).toHaveProperty('call_tool');
|
|
74
|
+
expect(executors).toHaveProperty('finish');
|
|
75
|
+
expect(executors).toHaveProperty('request_human_approve');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should allow config executors to override built-in ones', () => {
|
|
79
|
+
const agent = new MockAgent();
|
|
80
|
+
const customFinish = vi.fn();
|
|
81
|
+
|
|
82
|
+
const config: RuntimeConfig = {
|
|
83
|
+
executors: {
|
|
84
|
+
finish: customFinish,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const runtime = new AgentRuntime(agent, config);
|
|
89
|
+
|
|
90
|
+
// @ts-ignore
|
|
91
|
+
expect(runtime.executors.finish).toBe(customFinish);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should give agent executors highest priority', () => {
|
|
95
|
+
const agent = new MockAgent();
|
|
96
|
+
const agentFinish = vi.fn();
|
|
97
|
+
const configFinish = vi.fn();
|
|
98
|
+
|
|
99
|
+
agent.executors = { finish: agentFinish };
|
|
100
|
+
const config: RuntimeConfig = {
|
|
101
|
+
executors: { finish: configFinish },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const runtime = new AgentRuntime(agent, config);
|
|
105
|
+
|
|
106
|
+
// @ts-ignore
|
|
107
|
+
expect(runtime.executors.finish).toBe(agentFinish);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('step method', () => {
|
|
112
|
+
it('should execute approved tool call directly', async () => {
|
|
113
|
+
const agent = new MockAgent();
|
|
114
|
+
agent.tools = {
|
|
115
|
+
test_tool: vi.fn().mockResolvedValue({ result: 'success' }),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const runtime = new AgentRuntime(agent);
|
|
119
|
+
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
120
|
+
|
|
121
|
+
const toolCall: ToolsCalling = {
|
|
122
|
+
id: 'call_123',
|
|
123
|
+
type: 'function',
|
|
124
|
+
function: {
|
|
125
|
+
name: 'test_tool',
|
|
126
|
+
arguments: '{"input": "test"}',
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const result = await runtime.approveToolCall(state, toolCall);
|
|
131
|
+
|
|
132
|
+
expect(result.events).toHaveLength(1);
|
|
133
|
+
expect(result.events[0]).toMatchObject({
|
|
134
|
+
type: 'tool_result',
|
|
135
|
+
id: 'call_123',
|
|
136
|
+
result: { result: 'success' },
|
|
137
|
+
});
|
|
138
|
+
expect(result.newState.messages).toHaveLength(1);
|
|
139
|
+
expect(result.newState.messages[0].role).toBe('tool');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should follow agent runner -> executor flow', async () => {
|
|
143
|
+
const agent = new MockAgent();
|
|
144
|
+
const runtime = new AgentRuntime(agent);
|
|
145
|
+
const state = AgentRuntime.createInitialState({
|
|
146
|
+
sessionId: 'test-session',
|
|
147
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const result = await runtime.step(state);
|
|
151
|
+
|
|
152
|
+
// Should call agent runner, get call_llm instruction, but fail due to no llmProvider
|
|
153
|
+
expect(result.events).toHaveLength(1);
|
|
154
|
+
expect(result.events[0].type).toBe('error');
|
|
155
|
+
expect(result.newState.status).toBe('error');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should handle errors gracefully', async () => {
|
|
159
|
+
const agent = new MockAgent();
|
|
160
|
+
agent.runner = vi.fn().mockImplementation(() => Promise.reject(new Error('Agent error')));
|
|
161
|
+
|
|
162
|
+
const runtime = new AgentRuntime(agent);
|
|
163
|
+
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
164
|
+
|
|
165
|
+
const result = await runtime.step(state);
|
|
166
|
+
|
|
167
|
+
expect(result.events).toHaveLength(1);
|
|
168
|
+
expect(result.events[0]).toMatchObject({
|
|
169
|
+
type: 'error',
|
|
170
|
+
error: expect.any(Error),
|
|
171
|
+
});
|
|
172
|
+
expect(result.newState.status).toBe('error');
|
|
173
|
+
expect(result.newState.error).toBeInstanceOf(Error);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('Built-in Executors', () => {
|
|
178
|
+
describe('call_llm executor', () => {
|
|
179
|
+
it('should require modelRuntime', async () => {
|
|
180
|
+
const agent = new MockAgent();
|
|
181
|
+
const runtime = new AgentRuntime(agent);
|
|
182
|
+
const state = AgentRuntime.createInitialState({
|
|
183
|
+
sessionId: 'test-session',
|
|
184
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const result = await runtime.step(state);
|
|
188
|
+
|
|
189
|
+
expect(result.events[0].type).toBe('error');
|
|
190
|
+
expect((result.events[0] as AgentEventError).error.message).toContain(
|
|
191
|
+
'Model Runtime is required',
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should handle streaming LLM response', async () => {
|
|
196
|
+
const agent = new MockAgent();
|
|
197
|
+
|
|
198
|
+
async function* mockModelRuntime(payload: unknown) {
|
|
199
|
+
yield { content: 'Hello' };
|
|
200
|
+
yield { content: ' world' };
|
|
201
|
+
yield { content: '!' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
agent.modelRuntime = mockModelRuntime;
|
|
205
|
+
|
|
206
|
+
const runtime = new AgentRuntime(agent);
|
|
207
|
+
const state = AgentRuntime.createInitialState({
|
|
208
|
+
sessionId: 'test-session',
|
|
209
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const result = await runtime.step(state);
|
|
213
|
+
|
|
214
|
+
expect(result.events).toHaveLength(5); // start + 3 streams + result
|
|
215
|
+
expect(result.events[0]).toMatchObject({
|
|
216
|
+
type: 'llm_start',
|
|
217
|
+
payload: expect.anything(),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(result.events[1]).toMatchObject({
|
|
221
|
+
type: 'llm_stream',
|
|
222
|
+
chunk: { content: 'Hello' },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(result.events[4]).toMatchObject({
|
|
226
|
+
type: 'llm_result',
|
|
227
|
+
result: { content: 'Hello world!', tool_calls: [] },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// In the new architecture, call_llm executor doesn't add messages to state
|
|
231
|
+
// It only returns events, messages should be handled by higher-level logic
|
|
232
|
+
expect(result.newState.messages).toHaveLength(1); // Only user message
|
|
233
|
+
expect(result.newState.status).toBe('running');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should handle LLM response with tool calls', async () => {
|
|
237
|
+
const agent = new MockAgent();
|
|
238
|
+
|
|
239
|
+
async function* mockModelRuntime(payload: unknown) {
|
|
240
|
+
yield { content: 'I need to use a tool' };
|
|
241
|
+
yield {
|
|
242
|
+
tool_calls: [
|
|
243
|
+
{
|
|
244
|
+
id: 'call_123',
|
|
245
|
+
type: 'function' as const,
|
|
246
|
+
function: { name: 'test_tool', arguments: '{}' },
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
agent.modelRuntime = mockModelRuntime;
|
|
253
|
+
|
|
254
|
+
const runtime = new AgentRuntime(agent);
|
|
255
|
+
const state = AgentRuntime.createInitialState({
|
|
256
|
+
sessionId: 'test-session',
|
|
257
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const result = await runtime.step(state);
|
|
261
|
+
|
|
262
|
+
// In the new architecture, call_llm executor doesn't add messages to state
|
|
263
|
+
// Check that the events contain the expected LLM result
|
|
264
|
+
expect(result.events).toContainEqual(
|
|
265
|
+
expect.objectContaining({
|
|
266
|
+
type: 'llm_result',
|
|
267
|
+
result: expect.objectContaining({
|
|
268
|
+
content: 'I need to use a tool',
|
|
269
|
+
tool_calls: [
|
|
270
|
+
{
|
|
271
|
+
id: 'call_123',
|
|
272
|
+
type: 'function',
|
|
273
|
+
function: { name: 'test_tool', arguments: '{}' },
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
}),
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('call_tool executor', () => {
|
|
283
|
+
it('should execute tool and add result to messages', async () => {
|
|
284
|
+
const agent = new MockAgent();
|
|
285
|
+
agent.tools = {
|
|
286
|
+
calculator: vi.fn().mockResolvedValue({ result: 42 }),
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const runtime = new AgentRuntime(agent);
|
|
290
|
+
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
291
|
+
|
|
292
|
+
const toolCall: ToolsCalling = {
|
|
293
|
+
id: 'call_123',
|
|
294
|
+
type: 'function',
|
|
295
|
+
function: {
|
|
296
|
+
name: 'calculator',
|
|
297
|
+
arguments: '{"expression": "2+2"}',
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const result = await runtime.approveToolCall(state, toolCall);
|
|
302
|
+
|
|
303
|
+
expect((agent.tools as any).calculator).toHaveBeenCalledWith({ expression: '2+2' });
|
|
304
|
+
expect(result.events).toHaveLength(1);
|
|
305
|
+
expect(result.events[0]).toMatchObject({
|
|
306
|
+
type: 'tool_result',
|
|
307
|
+
id: 'call_123',
|
|
308
|
+
result: { result: 42 },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(result.newState.messages).toHaveLength(1);
|
|
312
|
+
expect(result.newState.messages[0]).toMatchObject({
|
|
313
|
+
role: 'tool',
|
|
314
|
+
tool_call_id: 'call_123',
|
|
315
|
+
content: '{"result":42}',
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should throw error for unknown tool', async () => {
|
|
320
|
+
const agent = new MockAgent();
|
|
321
|
+
const runtime = new AgentRuntime(agent);
|
|
322
|
+
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
323
|
+
|
|
324
|
+
const toolCall: ToolsCalling = {
|
|
325
|
+
id: 'call_123',
|
|
326
|
+
type: 'function',
|
|
327
|
+
function: {
|
|
328
|
+
name: 'unknown_tool',
|
|
329
|
+
arguments: '{}',
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const result = await runtime.approveToolCall(state, toolCall);
|
|
334
|
+
|
|
335
|
+
expect(result.events[0].type).toBe('error');
|
|
336
|
+
expect((result.events[0] as AgentEventError).error.message).toContain(
|
|
337
|
+
'Tool not found: unknown_tool',
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe('human interaction executors', () => {
|
|
343
|
+
it('should handle human approve request', async () => {
|
|
344
|
+
const agent = new MockAgent();
|
|
345
|
+
// Mock agent to return human approve instruction
|
|
346
|
+
agent.runner = vi.fn().mockImplementation(() =>
|
|
347
|
+
Promise.resolve({
|
|
348
|
+
type: 'request_human_approve',
|
|
349
|
+
pendingToolsCalling: [
|
|
350
|
+
{
|
|
351
|
+
id: 'call_123',
|
|
352
|
+
type: 'function',
|
|
353
|
+
function: { name: 'test_tool', arguments: '{}' },
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const runtime = new AgentRuntime(agent);
|
|
360
|
+
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
361
|
+
|
|
362
|
+
const result = await runtime.step(state);
|
|
363
|
+
|
|
364
|
+
expect(result.events).toHaveLength(2);
|
|
365
|
+
expect(result.events[0]).toMatchObject({
|
|
366
|
+
type: 'human_approve_required',
|
|
367
|
+
sessionId: 'test-session',
|
|
368
|
+
});
|
|
369
|
+
expect(result.events[1]).toMatchObject({
|
|
370
|
+
type: 'tool_pending',
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(result.newState.status).toBe('waiting_for_human_input');
|
|
374
|
+
expect(result.newState.pendingToolsCalling).toBeDefined();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('should handle human prompt request', async () => {
|
|
378
|
+
const agent = new MockAgent();
|
|
379
|
+
agent.runner = vi.fn().mockImplementation(() =>
|
|
380
|
+
Promise.resolve({
|
|
381
|
+
type: 'request_human_prompt',
|
|
382
|
+
prompt: 'Please provide input',
|
|
383
|
+
metadata: { key: 'value' },
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const runtime = new AgentRuntime(agent);
|
|
388
|
+
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
389
|
+
|
|
390
|
+
const result = await runtime.step(state);
|
|
391
|
+
|
|
392
|
+
expect(result.events).toHaveLength(1);
|
|
393
|
+
expect(result.events[0]).toMatchObject({
|
|
394
|
+
type: 'human_prompt_required',
|
|
395
|
+
prompt: 'Please provide input',
|
|
396
|
+
metadata: { key: 'value' },
|
|
397
|
+
sessionId: 'test-session',
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(result.newState.status).toBe('waiting_for_human_input');
|
|
401
|
+
expect(result.newState.pendingHumanPrompt).toEqual({
|
|
402
|
+
prompt: 'Please provide input',
|
|
403
|
+
metadata: { key: 'value' },
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should handle human select request', async () => {
|
|
408
|
+
const agent = new MockAgent();
|
|
409
|
+
agent.runner = vi.fn().mockImplementation(() =>
|
|
410
|
+
Promise.resolve({
|
|
411
|
+
type: 'request_human_select',
|
|
412
|
+
prompt: 'Choose an option',
|
|
413
|
+
options: [
|
|
414
|
+
{ label: 'Option 1', value: 'opt1' },
|
|
415
|
+
{ label: 'Option 2', value: 'opt2' },
|
|
416
|
+
],
|
|
417
|
+
multi: false,
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const runtime = new AgentRuntime(agent);
|
|
422
|
+
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
423
|
+
|
|
424
|
+
const result = await runtime.step(state);
|
|
425
|
+
|
|
426
|
+
expect(result.events).toHaveLength(1);
|
|
427
|
+
expect(result.events[0]).toMatchObject({
|
|
428
|
+
type: 'human_select_required',
|
|
429
|
+
prompt: 'Choose an option',
|
|
430
|
+
options: [
|
|
431
|
+
{ label: 'Option 1', value: 'opt1' },
|
|
432
|
+
{ label: 'Option 2', value: 'opt2' },
|
|
433
|
+
],
|
|
434
|
+
multi: false,
|
|
435
|
+
sessionId: 'test-session',
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
expect(result.newState.status).toBe('waiting_for_human_input');
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe('finish executor', () => {
|
|
443
|
+
it('should mark conversation as done', async () => {
|
|
444
|
+
const agent = new MockAgent();
|
|
445
|
+
agent.runner = vi.fn().mockImplementation(() =>
|
|
446
|
+
Promise.resolve({
|
|
447
|
+
type: 'finish',
|
|
448
|
+
reason: 'completed',
|
|
449
|
+
reasonDetail: 'Task completed',
|
|
450
|
+
}),
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const runtime = new AgentRuntime(agent);
|
|
454
|
+
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
455
|
+
|
|
456
|
+
const result = await runtime.step(state);
|
|
457
|
+
|
|
458
|
+
expect(result.events).toHaveLength(1);
|
|
459
|
+
expect(result.events[0]).toMatchObject({
|
|
460
|
+
type: 'done',
|
|
461
|
+
finalState: expect.objectContaining({
|
|
462
|
+
status: 'done',
|
|
463
|
+
}),
|
|
464
|
+
reason: 'completed',
|
|
465
|
+
reasonDetail: 'Task completed',
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
expect(result.newState.status).toBe('done');
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
describe('createInitialState', () => {
|
|
474
|
+
it('should create initial state without message', () => {
|
|
475
|
+
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
476
|
+
|
|
477
|
+
expect(state).toMatchObject({
|
|
478
|
+
sessionId: 'test-session',
|
|
479
|
+
status: 'idle',
|
|
480
|
+
messages: [],
|
|
481
|
+
stepCount: 0,
|
|
482
|
+
createdAt: expect.any(String),
|
|
483
|
+
lastModified: expect.any(String),
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('should create initial state with message', () => {
|
|
488
|
+
const state = AgentRuntime.createInitialState({
|
|
489
|
+
sessionId: 'test-session',
|
|
490
|
+
messages: [{ role: 'user', content: 'Hello world' }],
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
expect(state.messages).toHaveLength(1);
|
|
494
|
+
expect(state.messages[0]).toMatchObject({
|
|
495
|
+
role: 'user',
|
|
496
|
+
content: 'Hello world',
|
|
497
|
+
});
|
|
498
|
+
expect(state.stepCount).toBe(0);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should create initial state with custom stepCount', () => {
|
|
502
|
+
const state = AgentRuntime.createInitialState({
|
|
503
|
+
sessionId: 'test-session',
|
|
504
|
+
stepCount: 5,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
expect(state.stepCount).toBe(5);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('should create initial state with maxSteps limit', () => {
|
|
511
|
+
const state = AgentRuntime.createInitialState({
|
|
512
|
+
sessionId: 'test-session',
|
|
513
|
+
maxSteps: 10,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
expect(state.maxSteps).toBe(10);
|
|
517
|
+
expect(state.stepCount).toBe(0);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe('Step Count Tracking', () => {
|
|
522
|
+
it('should increment stepCount on each step execution', async () => {
|
|
523
|
+
const agent = new MockAgent();
|
|
524
|
+
const runtime = new AgentRuntime(agent);
|
|
525
|
+
|
|
526
|
+
let state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
527
|
+
expect(state.stepCount).toBe(0);
|
|
528
|
+
|
|
529
|
+
// First step
|
|
530
|
+
const result1 = await runtime.step(state, createTestContext('user_input'));
|
|
531
|
+
expect(result1.newState.stepCount).toBe(1);
|
|
532
|
+
|
|
533
|
+
// Second step
|
|
534
|
+
const result2 = await runtime.step(result1.newState, createTestContext('user_input'));
|
|
535
|
+
expect(result2.newState.stepCount).toBe(2);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('should respect maxSteps limit', async () => {
|
|
539
|
+
const agent = new MockAgent();
|
|
540
|
+
// Add a mock modelRuntime to avoid LLM provider error
|
|
541
|
+
agent.modelRuntime = async function* () {
|
|
542
|
+
yield { content: 'test response' };
|
|
543
|
+
};
|
|
544
|
+
const runtime = new AgentRuntime(agent);
|
|
545
|
+
|
|
546
|
+
const state = AgentRuntime.createInitialState({
|
|
547
|
+
sessionId: 'test-session',
|
|
548
|
+
maxSteps: 3, // 允许 3 步
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// First step - should work
|
|
552
|
+
const result1 = await runtime.step(state, createTestContext('user_input'));
|
|
553
|
+
expect(result1.newState.stepCount).toBe(1);
|
|
554
|
+
expect(result1.newState.status).not.toBe('error');
|
|
555
|
+
|
|
556
|
+
// Second step - should work
|
|
557
|
+
const result2 = await runtime.step(result1.newState, createTestContext('user_input'));
|
|
558
|
+
expect(result2.newState.stepCount).toBe(2);
|
|
559
|
+
expect(result2.newState.status).not.toBe('error');
|
|
560
|
+
|
|
561
|
+
// Third step - should work (at limit)
|
|
562
|
+
const result3 = await runtime.step(result2.newState, createTestContext('user_input'));
|
|
563
|
+
expect(result3.newState.stepCount).toBe(3);
|
|
564
|
+
expect(result3.newState.status).not.toBe('error');
|
|
565
|
+
|
|
566
|
+
// Fourth step - should finish due to maxSteps
|
|
567
|
+
const result4 = await runtime.step(result3.newState, createTestContext('user_input'));
|
|
568
|
+
expect(result4.newState.stepCount).toBe(4);
|
|
569
|
+
expect(result4.newState.status).toBe('done');
|
|
570
|
+
expect(result4.events[0]).toMatchObject({
|
|
571
|
+
type: 'done',
|
|
572
|
+
finalState: expect.objectContaining({
|
|
573
|
+
status: 'done',
|
|
574
|
+
}),
|
|
575
|
+
reason: 'max_steps_exceeded',
|
|
576
|
+
reasonDetail: 'Maximum steps exceeded: 3',
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('should include stepCount in session context', async () => {
|
|
581
|
+
const agent = new MockAgent();
|
|
582
|
+
// Mock agent to check the context it receives
|
|
583
|
+
const runnerSpy = vi.spyOn(agent, 'runner');
|
|
584
|
+
|
|
585
|
+
const runtime = new AgentRuntime(agent);
|
|
586
|
+
const state = AgentRuntime.createInitialState({
|
|
587
|
+
sessionId: 'test-session',
|
|
588
|
+
stepCount: 5, // Start with step 5
|
|
589
|
+
messages: [{ role: 'user', content: 'test' }],
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// Don't provide context, let runtime create it with updated stepCount
|
|
593
|
+
await runtime.step(state);
|
|
594
|
+
|
|
595
|
+
// Check that agent received correct stepCount in context
|
|
596
|
+
expect(runnerSpy).toHaveBeenCalledWith(
|
|
597
|
+
expect.objectContaining({
|
|
598
|
+
session: expect.objectContaining({
|
|
599
|
+
stepCount: 6, // Should be incremented
|
|
600
|
+
}),
|
|
601
|
+
}),
|
|
602
|
+
expect.any(Object),
|
|
603
|
+
);
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
describe('Interruption Handling', () => {
|
|
608
|
+
it('should interrupt execution with reason and metadata', () => {
|
|
609
|
+
const agent = new MockAgent();
|
|
610
|
+
const runtime = new AgentRuntime(agent);
|
|
611
|
+
|
|
612
|
+
const state = AgentRuntime.createInitialState({
|
|
613
|
+
sessionId: 'test-session',
|
|
614
|
+
stepCount: 3,
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const result = runtime.interrupt(state, 'User requested stop', true, {
|
|
618
|
+
userAction: 'stop_button',
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
expect(result.newState.status).toBe('interrupted');
|
|
622
|
+
expect(result.newState.interruption).toMatchObject({
|
|
623
|
+
reason: 'User requested stop',
|
|
624
|
+
canResume: true,
|
|
625
|
+
interruptedAt: expect.any(String),
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
expect(result.events[0]).toMatchObject({
|
|
629
|
+
type: 'interrupted',
|
|
630
|
+
reason: 'User requested stop',
|
|
631
|
+
canResume: true,
|
|
632
|
+
metadata: { userAction: 'stop_button' },
|
|
633
|
+
interruptedAt: expect.any(String),
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('should resume from interrupted state', async () => {
|
|
638
|
+
const agent = new MockAgent();
|
|
639
|
+
agent.modelRuntime = async function* () {
|
|
640
|
+
yield { content: 'resumed response' };
|
|
641
|
+
};
|
|
642
|
+
const runtime = new AgentRuntime(agent);
|
|
643
|
+
|
|
644
|
+
// Create interrupted state
|
|
645
|
+
let state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
646
|
+
const interruptResult = runtime.interrupt(state, 'Test interruption');
|
|
647
|
+
|
|
648
|
+
// Resume execution
|
|
649
|
+
const resumeResult = await runtime.resume(interruptResult.newState, 'Test resume');
|
|
650
|
+
|
|
651
|
+
expect(resumeResult.newState.status).toBe('running');
|
|
652
|
+
expect(resumeResult.newState.interruption).toBeUndefined();
|
|
653
|
+
|
|
654
|
+
expect(resumeResult.events[0]).toMatchObject({
|
|
655
|
+
type: 'resumed',
|
|
656
|
+
reason: 'Test resume',
|
|
657
|
+
resumedFromStep: 0,
|
|
658
|
+
resumedAt: expect.any(String),
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('should not allow resume if canResume is false', async () => {
|
|
663
|
+
const agent = new MockAgent();
|
|
664
|
+
const runtime = new AgentRuntime(agent);
|
|
665
|
+
|
|
666
|
+
let state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
667
|
+
const interruptResult = runtime.interrupt(state, 'Fatal error', false);
|
|
668
|
+
|
|
669
|
+
await expect(runtime.resume(interruptResult.newState)).rejects.toThrow(
|
|
670
|
+
'Cannot resume: interruption is not resumable',
|
|
671
|
+
);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('should not allow resume from non-interrupted state', async () => {
|
|
675
|
+
const agent = new MockAgent();
|
|
676
|
+
const runtime = new AgentRuntime(agent);
|
|
677
|
+
|
|
678
|
+
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
679
|
+
|
|
680
|
+
await expect(runtime.resume(state)).rejects.toThrow(
|
|
681
|
+
'Cannot resume: state is not interrupted',
|
|
682
|
+
);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('should resume with specific context', async () => {
|
|
686
|
+
const agent = new MockAgent();
|
|
687
|
+
agent.modelRuntime = async function* () {
|
|
688
|
+
yield { content: 'context-specific response' };
|
|
689
|
+
};
|
|
690
|
+
const runtime = new AgentRuntime(agent);
|
|
691
|
+
|
|
692
|
+
let state = AgentRuntime.createInitialState({
|
|
693
|
+
sessionId: 'test-session',
|
|
694
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
695
|
+
});
|
|
696
|
+
const interruptResult = runtime.interrupt(state, 'Test interruption');
|
|
697
|
+
|
|
698
|
+
const resumeContext: RuntimeContext = {
|
|
699
|
+
phase: 'user_input',
|
|
700
|
+
payload: { message: { role: 'user', content: 'Hello' } },
|
|
701
|
+
session: {
|
|
702
|
+
sessionId: 'test-session',
|
|
703
|
+
messageCount: 1,
|
|
704
|
+
eventCount: 2, // init + interrupt
|
|
705
|
+
status: 'interrupted',
|
|
706
|
+
stepCount: 0,
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const resumeResult = await runtime.resume(
|
|
711
|
+
interruptResult.newState,
|
|
712
|
+
'Resume with context',
|
|
713
|
+
resumeContext,
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
expect(resumeResult.events.length).toBeGreaterThanOrEqual(2); // resume + llm events (start, stream, result)
|
|
717
|
+
expect(resumeResult.events[0].type).toBe('resumed');
|
|
718
|
+
expect(resumeResult.newState.status).toBe('running');
|
|
719
|
+
|
|
720
|
+
// Should contain LLM execution events
|
|
721
|
+
expect(resumeResult.events.map((e) => e.type)).toContain('llm_start');
|
|
722
|
+
expect(resumeResult.events.map((e) => e.type)).toContain('llm_result');
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
describe('Usage and Cost Tracking', () => {
|
|
727
|
+
it('should initialize with zero usage and cost', () => {
|
|
728
|
+
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
|
|
729
|
+
|
|
730
|
+
expect(state.usage).toMatchObject({
|
|
731
|
+
llm: {
|
|
732
|
+
tokens: { input: 0, output: 0, total: 0 },
|
|
733
|
+
apiCalls: 0,
|
|
734
|
+
processingTimeMs: 0,
|
|
735
|
+
},
|
|
736
|
+
tools: {
|
|
737
|
+
totalCalls: 0,
|
|
738
|
+
byTool: {},
|
|
739
|
+
totalTimeMs: 0,
|
|
740
|
+
},
|
|
741
|
+
humanInteraction: {
|
|
742
|
+
approvalRequests: 0,
|
|
743
|
+
promptRequests: 0,
|
|
744
|
+
selectRequests: 0,
|
|
745
|
+
totalWaitingTimeMs: 0,
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
expect(state.cost).toMatchObject({
|
|
750
|
+
llm: {
|
|
751
|
+
byModel: {},
|
|
752
|
+
total: 0,
|
|
753
|
+
currency: 'USD',
|
|
754
|
+
},
|
|
755
|
+
tools: {
|
|
756
|
+
byTool: {},
|
|
757
|
+
total: 0,
|
|
758
|
+
currency: 'USD',
|
|
759
|
+
},
|
|
760
|
+
total: 0,
|
|
761
|
+
currency: 'USD',
|
|
762
|
+
calculatedAt: expect.any(String),
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('should track usage and cost through agent methods', async () => {
|
|
767
|
+
// Create agent with cost calculation methods
|
|
768
|
+
class CostTrackingAgent implements Agent {
|
|
769
|
+
tools = {
|
|
770
|
+
test_tool: async () => ({ result: 'success' }),
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
async runner(context: RuntimeContext, state: AgentState) {
|
|
774
|
+
switch (context.phase) {
|
|
775
|
+
case 'user_input':
|
|
776
|
+
return { type: 'call_llm' as const, payload: { messages: state.messages } };
|
|
777
|
+
default:
|
|
778
|
+
return {
|
|
779
|
+
type: 'finish' as const,
|
|
780
|
+
reason: 'completed' as const,
|
|
781
|
+
reasonDetail: 'Done',
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
calculateUsage(
|
|
787
|
+
operationType: 'llm' | 'tool' | 'human_interaction',
|
|
788
|
+
operationResult: any,
|
|
789
|
+
previousUsage: Usage,
|
|
790
|
+
): Usage {
|
|
791
|
+
const newUsage = structuredClone(previousUsage);
|
|
792
|
+
|
|
793
|
+
if (operationType === 'llm') {
|
|
794
|
+
newUsage.llm.tokens.input += 100;
|
|
795
|
+
newUsage.llm.tokens.output += 50;
|
|
796
|
+
newUsage.llm.tokens.total += 150;
|
|
797
|
+
newUsage.llm.apiCalls += 1;
|
|
798
|
+
newUsage.llm.processingTimeMs += 1000;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return newUsage;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
calculateCost(context: CostCalculationContext): Cost {
|
|
805
|
+
const newCost = structuredClone(context.previousCost || (context.usage as any));
|
|
806
|
+
|
|
807
|
+
// Simple cost calculation: $0.01 per 1000 tokens
|
|
808
|
+
const tokenCost = (context.usage.llm.tokens.total / 1000) * 0.01;
|
|
809
|
+
newCost.llm.total = tokenCost;
|
|
810
|
+
newCost.total = tokenCost;
|
|
811
|
+
newCost.calculatedAt = new Date().toISOString();
|
|
812
|
+
|
|
813
|
+
return newCost;
|
|
814
|
+
}
|
|
815
|
+
modelRuntime = async function* () {
|
|
816
|
+
yield { content: 'test response' };
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const agent = new CostTrackingAgent();
|
|
821
|
+
const runtime = new AgentRuntime(agent);
|
|
822
|
+
|
|
823
|
+
const state = AgentRuntime.createInitialState({
|
|
824
|
+
sessionId: 'test-session',
|
|
825
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
const result = await runtime.step(state, createTestContext('user_input'));
|
|
829
|
+
|
|
830
|
+
// Should have updated usage
|
|
831
|
+
expect(result.newState.usage.llm.tokens.total).toBe(150);
|
|
832
|
+
expect(result.newState.usage.llm.apiCalls).toBe(1);
|
|
833
|
+
|
|
834
|
+
// Should have calculated cost
|
|
835
|
+
expect(result.newState.cost.total).toBe(0.0015); // 150 tokens * $0.01/1000
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('should respect cost limits with stop action', async () => {
|
|
839
|
+
class CostTrackingAgent implements Agent {
|
|
840
|
+
async runner(context: RuntimeContext, state: AgentState) {
|
|
841
|
+
return { type: 'call_llm' as const, payload: { messages: state.messages } };
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
calculateUsage(operationType: string, operationResult: any, previousUsage: Usage): Usage {
|
|
845
|
+
const newUsage = structuredClone(previousUsage);
|
|
846
|
+
newUsage.llm.tokens.total += 1000; // High token usage
|
|
847
|
+
return newUsage;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
calculateCost(context: CostCalculationContext): Cost {
|
|
851
|
+
const newCost = structuredClone(context.previousCost || ({} as Cost));
|
|
852
|
+
newCost.total = 10.0; // High cost that exceeds limit
|
|
853
|
+
newCost.currency = 'USD';
|
|
854
|
+
newCost.calculatedAt = new Date().toISOString();
|
|
855
|
+
return newCost;
|
|
856
|
+
}
|
|
857
|
+
modelRuntime = async function* () {
|
|
858
|
+
yield { content: 'test response' };
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const agent = new CostTrackingAgent();
|
|
863
|
+
const runtime = new AgentRuntime(agent);
|
|
864
|
+
|
|
865
|
+
const costLimit: CostLimit = {
|
|
866
|
+
maxTotalCost: 5.0,
|
|
867
|
+
currency: 'USD',
|
|
868
|
+
onExceeded: 'stop',
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
const state = AgentRuntime.createInitialState({
|
|
872
|
+
sessionId: 'test-session',
|
|
873
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
874
|
+
costLimit,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
const result = await runtime.step(state, createTestContext('user_input'));
|
|
878
|
+
|
|
879
|
+
expect(result.newState.status).toBe('done');
|
|
880
|
+
expect(result.events[0]).toMatchObject({
|
|
881
|
+
type: 'done',
|
|
882
|
+
reason: 'cost_limit_exceeded',
|
|
883
|
+
reasonDetail: expect.stringContaining('Cost limit exceeded'),
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it('should handle cost limit with interrupt action', async () => {
|
|
888
|
+
class CostTrackingAgent implements Agent {
|
|
889
|
+
async runner(context: RuntimeContext, state: AgentState) {
|
|
890
|
+
return { type: 'call_llm' as const, payload: { messages: state.messages } };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
calculateCost(context: CostCalculationContext): Cost {
|
|
894
|
+
return {
|
|
895
|
+
llm: { byModel: {}, total: 15.0, currency: 'USD' },
|
|
896
|
+
tools: { byTool: {}, total: 0, currency: 'USD' },
|
|
897
|
+
total: 15.0,
|
|
898
|
+
currency: 'USD',
|
|
899
|
+
calculatedAt: new Date().toISOString(),
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
modelRuntime = async function* () {
|
|
903
|
+
yield { content: 'test response' };
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const agent = new CostTrackingAgent();
|
|
908
|
+
const runtime = new AgentRuntime(agent);
|
|
909
|
+
|
|
910
|
+
const costLimit: CostLimit = {
|
|
911
|
+
maxTotalCost: 10.0,
|
|
912
|
+
currency: 'USD',
|
|
913
|
+
onExceeded: 'interrupt',
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const state = AgentRuntime.createInitialState({
|
|
917
|
+
sessionId: 'test-session',
|
|
918
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
919
|
+
costLimit,
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
const result = await runtime.step(state, createTestContext('user_input'));
|
|
923
|
+
|
|
924
|
+
expect(result.newState.status).toBe('interrupted');
|
|
925
|
+
expect(result.events[0]).toMatchObject({
|
|
926
|
+
type: 'interrupted',
|
|
927
|
+
reason: expect.stringContaining('Cost limit exceeded'),
|
|
928
|
+
metadata: expect.objectContaining({
|
|
929
|
+
costExceeded: true,
|
|
930
|
+
}),
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
describe('Integration Tests', () => {
|
|
936
|
+
it('should complete a full conversation flow', async () => {
|
|
937
|
+
const agent = new MockAgent();
|
|
938
|
+
agent.tools = {
|
|
939
|
+
get_weather: vi.fn().mockResolvedValue({
|
|
940
|
+
temperature: 25,
|
|
941
|
+
condition: 'sunny',
|
|
942
|
+
}),
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
// Mock agent behavior for different states
|
|
946
|
+
agent.runner = vi.fn().mockImplementation((context: RuntimeContext, state: AgentState) => {
|
|
947
|
+
switch (context.phase) {
|
|
948
|
+
case 'user_input':
|
|
949
|
+
return Promise.resolve({ type: 'call_llm', payload: { messages: state.messages } });
|
|
950
|
+
case 'llm_result':
|
|
951
|
+
const llmPayload = context.payload as { result: any; hasToolCalls: boolean };
|
|
952
|
+
if (llmPayload.hasToolCalls) {
|
|
953
|
+
return Promise.resolve({
|
|
954
|
+
type: 'request_human_approve',
|
|
955
|
+
pendingToolsCalling: llmPayload.result.tool_calls,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
return Promise.resolve({ type: 'finish', reason: 'completed', reasonDetail: 'Done' });
|
|
959
|
+
case 'tool_result':
|
|
960
|
+
return Promise.resolve({ type: 'call_llm', payload: { messages: state.messages } });
|
|
961
|
+
default:
|
|
962
|
+
return Promise.resolve({ type: 'finish', reason: 'completed', reasonDetail: 'Done' });
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
async function* mockModelRuntime(payload: unknown) {
|
|
967
|
+
const messages = (payload as any).messages;
|
|
968
|
+
const lastMessage = messages[messages.length - 1];
|
|
969
|
+
if (lastMessage.role === 'user') {
|
|
970
|
+
yield { content: "I'll check the weather for you." };
|
|
971
|
+
yield {
|
|
972
|
+
tool_calls: [
|
|
973
|
+
{
|
|
974
|
+
id: 'call_weather',
|
|
975
|
+
type: 'function' as const,
|
|
976
|
+
function: {
|
|
977
|
+
name: 'get_weather',
|
|
978
|
+
arguments: '{"city": "Beijing"}',
|
|
979
|
+
},
|
|
980
|
+
},
|
|
981
|
+
],
|
|
982
|
+
};
|
|
983
|
+
} else if (lastMessage.role === 'tool') {
|
|
984
|
+
yield { content: 'The weather in Beijing is 25°C and sunny.' };
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
agent.modelRuntime = mockModelRuntime;
|
|
989
|
+
const runtime = new AgentRuntime(agent);
|
|
990
|
+
|
|
991
|
+
// Step 1: User asks question
|
|
992
|
+
let state = AgentRuntime.createInitialState({
|
|
993
|
+
sessionId: 'test-session',
|
|
994
|
+
messages: [{ role: 'user', content: "What's the weather in Beijing?" }],
|
|
995
|
+
});
|
|
996
|
+
let result = await runtime.step(state);
|
|
997
|
+
|
|
998
|
+
// Should get LLM response with tool call (status is 'running' after LLM execution)
|
|
999
|
+
expect(result.newState.status).toBe('running');
|
|
1000
|
+
// In new architecture, call_llm doesn't add messages to state
|
|
1001
|
+
expect(result.newState.messages).toHaveLength(1); // Only user message
|
|
1002
|
+
// Check events contain the tool call result
|
|
1003
|
+
expect(result.events).toContainEqual(
|
|
1004
|
+
expect.objectContaining({
|
|
1005
|
+
type: 'llm_result',
|
|
1006
|
+
result: expect.objectContaining({
|
|
1007
|
+
tool_calls: expect.arrayContaining([
|
|
1008
|
+
expect.objectContaining({
|
|
1009
|
+
id: 'call_weather',
|
|
1010
|
+
type: 'function',
|
|
1011
|
+
}),
|
|
1012
|
+
]),
|
|
1013
|
+
}),
|
|
1014
|
+
}),
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
// Step 1.5: Agent processes assistant message with tool calls using nextContext
|
|
1018
|
+
result = await runtime.step(result.newState, result.nextContext);
|
|
1019
|
+
|
|
1020
|
+
// Now should request human approval
|
|
1021
|
+
expect(result.newState.status).toBe('waiting_for_human_input');
|
|
1022
|
+
expect(result.newState.pendingToolsCalling).toHaveLength(1);
|
|
1023
|
+
|
|
1024
|
+
// Step 2: Approve and execute tool call
|
|
1025
|
+
const toolCall = result.newState.pendingToolsCalling![0];
|
|
1026
|
+
result = await runtime.approveToolCall(result.newState, toolCall);
|
|
1027
|
+
|
|
1028
|
+
// Should have executed tool
|
|
1029
|
+
expect((agent.tools as any).get_weather).toHaveBeenCalledWith({ city: 'Beijing' });
|
|
1030
|
+
expect(result.newState.messages).toHaveLength(2); // user + tool result (call_tool executor adds tool message)
|
|
1031
|
+
|
|
1032
|
+
// Step 3: LLM processes tool result using nextContext
|
|
1033
|
+
result = await runtime.step(result.newState, result.nextContext);
|
|
1034
|
+
|
|
1035
|
+
// Should get final response in events
|
|
1036
|
+
expect(result.events).toContainEqual(
|
|
1037
|
+
expect.objectContaining({
|
|
1038
|
+
type: 'llm_result',
|
|
1039
|
+
result: expect.objectContaining({
|
|
1040
|
+
content: expect.stringContaining('25°C and sunny'),
|
|
1041
|
+
}),
|
|
1042
|
+
}),
|
|
1043
|
+
);
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
});
|