@objectstack/service-ai 4.0.3 → 4.0.5
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/README.md +293 -0
- package/dist/index.cjs +1176 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1225 -430
- package/dist/index.d.ts +1225 -430
- package/dist/index.js +1160 -128
- package/dist/index.js.map +1 -1
- package/package.json +35 -8
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -53
- package/src/__tests__/ai-service.test.ts +0 -964
- package/src/__tests__/auth-and-toolcalling.test.ts +0 -677
- package/src/__tests__/chatbot-features.test.ts +0 -1116
- package/src/__tests__/metadata-tools.test.ts +0 -970
- package/src/__tests__/objectql-conversation-service.test.ts +0 -382
- package/src/__tests__/tool-routes.test.ts +0 -191
- package/src/__tests__/vercel-stream-encoder.test.ts +0 -310
- package/src/adapters/index.ts +0 -6
- package/src/adapters/memory-adapter.ts +0 -72
- package/src/adapters/types.ts +0 -3
- package/src/adapters/vercel-adapter.ts +0 -148
- package/src/agent-runtime.ts +0 -154
- package/src/agents/data-chat-agent.ts +0 -79
- package/src/agents/index.ts +0 -4
- package/src/agents/metadata-assistant-agent.ts +0 -87
- package/src/ai-service.ts +0 -364
- package/src/conversation/in-memory-conversation-service.ts +0 -103
- package/src/conversation/index.ts +0 -4
- package/src/conversation/objectql-conversation-service.ts +0 -301
- package/src/index.ts +0 -60
- package/src/objects/ai-conversation.object.ts +0 -86
- package/src/objects/ai-message.object.ts +0 -86
- package/src/objects/index.ts +0 -10
- package/src/plugin.ts +0 -391
- package/src/routes/agent-routes.ts +0 -190
- package/src/routes/ai-routes.ts +0 -439
- package/src/routes/index.ts +0 -5
- package/src/routes/message-utils.ts +0 -90
- package/src/routes/tool-routes.ts +0 -142
- package/src/stream/index.ts +0 -3
- package/src/stream/vercel-stream-encoder.ts +0 -153
- package/src/tools/add-field.tool.ts +0 -70
- package/src/tools/create-object.tool.ts +0 -66
- package/src/tools/data-tools.ts +0 -293
- package/src/tools/delete-field.tool.ts +0 -38
- package/src/tools/describe-object.tool.ts +0 -31
- package/src/tools/index.ts +0 -18
- package/src/tools/list-objects.tool.ts +0 -34
- package/src/tools/metadata-tools.ts +0 -430
- package/src/tools/modify-field.tool.ts +0 -44
- package/src/tools/tool-registry.ts +0 -132
- package/tsconfig.json +0 -17
|
@@ -1,677 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
-
import type {
|
|
5
|
-
ModelMessage,
|
|
6
|
-
AIResult,
|
|
7
|
-
AIRequestOptions,
|
|
8
|
-
TextStreamPart,
|
|
9
|
-
ToolSet,
|
|
10
|
-
ToolCallPart,
|
|
11
|
-
LLMAdapter,
|
|
12
|
-
} from '@objectstack/spec/contracts';
|
|
13
|
-
import { AIService } from '../ai-service.js';
|
|
14
|
-
import { ToolRegistry } from '../tools/tool-registry.js';
|
|
15
|
-
import { buildAIRoutes } from '../routes/ai-routes.js';
|
|
16
|
-
import type { RouteDefinition, RouteUserContext } from '../routes/ai-routes.js';
|
|
17
|
-
import { InMemoryConversationService } from '../conversation/in-memory-conversation-service.js';
|
|
18
|
-
|
|
19
|
-
// ── Helpers ────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
const silentLogger = {
|
|
22
|
-
info: vi.fn(),
|
|
23
|
-
debug: vi.fn(),
|
|
24
|
-
warn: vi.fn(),
|
|
25
|
-
error: vi.fn(),
|
|
26
|
-
child: vi.fn().mockReturnThis(),
|
|
27
|
-
} as any;
|
|
28
|
-
|
|
29
|
-
function createMockAdapter(responses: AIResult[]): LLMAdapter {
|
|
30
|
-
let callIndex = 0;
|
|
31
|
-
return {
|
|
32
|
-
name: 'mock',
|
|
33
|
-
chat: vi.fn(async () => responses[callIndex++] ?? { content: 'done' }),
|
|
34
|
-
complete: vi.fn(async () => ({ content: '' })),
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function makeUser(userId: string, overrides: Partial<RouteUserContext> = {}): RouteUserContext {
|
|
39
|
-
return { userId, ...overrides };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
43
|
-
// Auth / Permissions Metadata Tests (≥5)
|
|
44
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
45
|
-
|
|
46
|
-
describe('Route Auth/Permissions Metadata', () => {
|
|
47
|
-
let routes: RouteDefinition[];
|
|
48
|
-
|
|
49
|
-
beforeEach(() => {
|
|
50
|
-
const service = new AIService({ logger: silentLogger });
|
|
51
|
-
routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('should declare auth=true on all routes', () => {
|
|
55
|
-
for (const route of routes) {
|
|
56
|
-
expect(route.auth).toBe(true);
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('should declare permissions on every route', () => {
|
|
61
|
-
for (const route of routes) {
|
|
62
|
-
expect(route.permissions).toBeDefined();
|
|
63
|
-
expect(Array.isArray(route.permissions)).toBe(true);
|
|
64
|
-
expect(route.permissions!.length).toBeGreaterThan(0);
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('should declare ai:chat permission for chat routes', () => {
|
|
69
|
-
const chatRoutes = routes.filter(
|
|
70
|
-
r => r.path === '/api/v1/ai/chat' || r.path === '/api/v1/ai/chat/stream',
|
|
71
|
-
);
|
|
72
|
-
expect(chatRoutes.length).toBe(2);
|
|
73
|
-
for (const route of chatRoutes) {
|
|
74
|
-
expect(route.permissions).toContain('ai:chat');
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('should declare ai:conversations permission for conversation routes', () => {
|
|
79
|
-
const convRoutes = routes.filter(r => r.path.includes('/conversations'));
|
|
80
|
-
expect(convRoutes.length).toBe(4);
|
|
81
|
-
for (const route of convRoutes) {
|
|
82
|
-
expect(route.permissions).toContain('ai:conversations');
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('should declare ai:read permission for models route', () => {
|
|
87
|
-
const modelsRoute = routes.find(r => r.path === '/api/v1/ai/models');
|
|
88
|
-
expect(modelsRoute).toBeDefined();
|
|
89
|
-
expect(modelsRoute!.permissions).toContain('ai:read');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('should declare ai:complete permission for complete route', () => {
|
|
93
|
-
const completeRoute = routes.find(r => r.path === '/api/v1/ai/complete');
|
|
94
|
-
expect(completeRoute).toBeDefined();
|
|
95
|
-
expect(completeRoute!.permissions).toContain('ai:complete');
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('should include description on every route', () => {
|
|
99
|
-
for (const route of routes) {
|
|
100
|
-
expect(typeof route.description).toBe('string');
|
|
101
|
-
expect(route.description.length).toBeGreaterThan(0);
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
107
|
-
// User Context / Ownership Tests
|
|
108
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
109
|
-
|
|
110
|
-
describe('Conversation Ownership Enforcement', () => {
|
|
111
|
-
let service: AIService;
|
|
112
|
-
let routes: RouteDefinition[];
|
|
113
|
-
|
|
114
|
-
beforeEach(() => {
|
|
115
|
-
service = new AIService({ logger: silentLogger });
|
|
116
|
-
routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// Helper to find routes
|
|
120
|
-
const getRoute = (method: string, path: string) =>
|
|
121
|
-
routes.find(r => r.method === method && r.path === path)!;
|
|
122
|
-
|
|
123
|
-
it('should bind userId to conversation when user context is present on create', async () => {
|
|
124
|
-
const createRoute = getRoute('POST', '/api/v1/ai/conversations');
|
|
125
|
-
const response = await createRoute.handler({
|
|
126
|
-
body: { title: 'Test' },
|
|
127
|
-
user: makeUser('user_1'),
|
|
128
|
-
});
|
|
129
|
-
expect(response.status).toBe(201);
|
|
130
|
-
expect((response.body as any).userId).toBe('user_1');
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('should return 400 for invalid request payload on create', async () => {
|
|
134
|
-
const createRoute = getRoute('POST', '/api/v1/ai/conversations');
|
|
135
|
-
|
|
136
|
-
// String body
|
|
137
|
-
const r1 = await createRoute.handler({ body: 'not an object' });
|
|
138
|
-
expect(r1.status).toBe(400);
|
|
139
|
-
expect((r1.body as any).error).toContain('Invalid request payload');
|
|
140
|
-
|
|
141
|
-
// Array body
|
|
142
|
-
const r2 = await createRoute.handler({ body: [1, 2, 3] });
|
|
143
|
-
expect(r2.status).toBe(400);
|
|
144
|
-
|
|
145
|
-
// Number body
|
|
146
|
-
const r3 = await createRoute.handler({ body: 42 });
|
|
147
|
-
expect(r3.status).toBe(400);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('should scope conversation listing to authenticated user', async () => {
|
|
151
|
-
const createRoute = getRoute('POST', '/api/v1/ai/conversations');
|
|
152
|
-
const listRoute = getRoute('GET', '/api/v1/ai/conversations');
|
|
153
|
-
|
|
154
|
-
// Create conversations for two different users
|
|
155
|
-
await createRoute.handler({ body: { title: 'User A conv' }, user: makeUser('user_a') });
|
|
156
|
-
await createRoute.handler({ body: { title: 'User B conv' }, user: makeUser('user_b') });
|
|
157
|
-
await createRoute.handler({ body: { title: 'User A conv 2' }, user: makeUser('user_a') });
|
|
158
|
-
|
|
159
|
-
// User A should only see their own conversations
|
|
160
|
-
const responseA = await listRoute.handler({ user: makeUser('user_a') });
|
|
161
|
-
expect(responseA.status).toBe(200);
|
|
162
|
-
expect((responseA.body as any).conversations).toHaveLength(2);
|
|
163
|
-
|
|
164
|
-
// User B should only see their own conversations
|
|
165
|
-
const responseB = await listRoute.handler({ user: makeUser('user_b') });
|
|
166
|
-
expect(responseB.status).toBe(200);
|
|
167
|
-
expect((responseB.body as any).conversations).toHaveLength(1);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('should reject adding a message to another user conversation', async () => {
|
|
171
|
-
const createRoute = getRoute('POST', '/api/v1/ai/conversations');
|
|
172
|
-
const addMsgRoute = getRoute('POST', '/api/v1/ai/conversations/:id/messages');
|
|
173
|
-
|
|
174
|
-
// Create a conversation owned by user_a
|
|
175
|
-
const created = await createRoute.handler({
|
|
176
|
-
body: {},
|
|
177
|
-
user: makeUser('user_a'),
|
|
178
|
-
});
|
|
179
|
-
const convId = (created.body as any).id;
|
|
180
|
-
|
|
181
|
-
// user_b tries to add a message → 403
|
|
182
|
-
const response = await addMsgRoute.handler({
|
|
183
|
-
params: { id: convId },
|
|
184
|
-
body: { role: 'user', content: 'Sneaky' },
|
|
185
|
-
user: makeUser('user_b'),
|
|
186
|
-
});
|
|
187
|
-
expect(response.status).toBe(403);
|
|
188
|
-
expect((response.body as any).error).toContain('do not have access');
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('should reject deleting another user conversation', async () => {
|
|
192
|
-
const createRoute = getRoute('POST', '/api/v1/ai/conversations');
|
|
193
|
-
const deleteRoute = getRoute('DELETE', '/api/v1/ai/conversations/:id');
|
|
194
|
-
|
|
195
|
-
const created = await createRoute.handler({
|
|
196
|
-
body: {},
|
|
197
|
-
user: makeUser('user_a'),
|
|
198
|
-
});
|
|
199
|
-
const convId = (created.body as any).id;
|
|
200
|
-
|
|
201
|
-
// user_b tries to delete → 403
|
|
202
|
-
const response = await deleteRoute.handler({
|
|
203
|
-
params: { id: convId },
|
|
204
|
-
user: makeUser('user_b'),
|
|
205
|
-
});
|
|
206
|
-
expect(response.status).toBe(403);
|
|
207
|
-
expect((response.body as any).error).toContain('do not have access');
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('should allow owner to add message to their own conversation', async () => {
|
|
211
|
-
const createRoute = getRoute('POST', '/api/v1/ai/conversations');
|
|
212
|
-
const addMsgRoute = getRoute('POST', '/api/v1/ai/conversations/:id/messages');
|
|
213
|
-
|
|
214
|
-
const created = await createRoute.handler({
|
|
215
|
-
body: {},
|
|
216
|
-
user: makeUser('user_a'),
|
|
217
|
-
});
|
|
218
|
-
const convId = (created.body as any).id;
|
|
219
|
-
|
|
220
|
-
const response = await addMsgRoute.handler({
|
|
221
|
-
params: { id: convId },
|
|
222
|
-
body: { role: 'user', content: 'Hello' },
|
|
223
|
-
user: makeUser('user_a'),
|
|
224
|
-
});
|
|
225
|
-
expect(response.status).toBe(200);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('should allow owner to delete their own conversation', async () => {
|
|
229
|
-
const createRoute = getRoute('POST', '/api/v1/ai/conversations');
|
|
230
|
-
const deleteRoute = getRoute('DELETE', '/api/v1/ai/conversations/:id');
|
|
231
|
-
|
|
232
|
-
const created = await createRoute.handler({
|
|
233
|
-
body: {},
|
|
234
|
-
user: makeUser('user_a'),
|
|
235
|
-
});
|
|
236
|
-
const convId = (created.body as any).id;
|
|
237
|
-
|
|
238
|
-
const response = await deleteRoute.handler({
|
|
239
|
-
params: { id: convId },
|
|
240
|
-
user: makeUser('user_a'),
|
|
241
|
-
});
|
|
242
|
-
expect(response.status).toBe(204);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it('should return 404 when adding message to non-existent conversation (with user context)', async () => {
|
|
246
|
-
const addMsgRoute = getRoute('POST', '/api/v1/ai/conversations/:id/messages');
|
|
247
|
-
|
|
248
|
-
const response = await addMsgRoute.handler({
|
|
249
|
-
params: { id: 'non_existent' },
|
|
250
|
-
body: { role: 'user', content: 'Hello' },
|
|
251
|
-
user: makeUser('user_a'),
|
|
252
|
-
});
|
|
253
|
-
expect(response.status).toBe(404);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('should return 404 when deleting non-existent conversation (with user context)', async () => {
|
|
257
|
-
const deleteRoute = getRoute('DELETE', '/api/v1/ai/conversations/:id');
|
|
258
|
-
|
|
259
|
-
const response = await deleteRoute.handler({
|
|
260
|
-
params: { id: 'non_existent' },
|
|
261
|
-
user: makeUser('user_a'),
|
|
262
|
-
});
|
|
263
|
-
expect(response.status).toBe(404);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it('should still work without user context (backward compatible)', async () => {
|
|
267
|
-
const createRoute = getRoute('POST', '/api/v1/ai/conversations');
|
|
268
|
-
const listRoute = getRoute('GET', '/api/v1/ai/conversations');
|
|
269
|
-
const addMsgRoute = getRoute('POST', '/api/v1/ai/conversations/:id/messages');
|
|
270
|
-
const deleteRoute = getRoute('DELETE', '/api/v1/ai/conversations/:id');
|
|
271
|
-
|
|
272
|
-
// Create without user context
|
|
273
|
-
const created = await createRoute.handler({ body: { title: 'No user' } });
|
|
274
|
-
expect(created.status).toBe(201);
|
|
275
|
-
const convId = (created.body as any).id;
|
|
276
|
-
|
|
277
|
-
// List without user context
|
|
278
|
-
const listed = await listRoute.handler({});
|
|
279
|
-
expect(listed.status).toBe(200);
|
|
280
|
-
|
|
281
|
-
// Add message without user context
|
|
282
|
-
const added = await addMsgRoute.handler({
|
|
283
|
-
params: { id: convId },
|
|
284
|
-
body: { role: 'user', content: 'Hi' },
|
|
285
|
-
});
|
|
286
|
-
expect(added.status).toBe(200);
|
|
287
|
-
|
|
288
|
-
// Delete without user context
|
|
289
|
-
const deleted = await deleteRoute.handler({ params: { id: convId } });
|
|
290
|
-
expect(deleted.status).toBe(204);
|
|
291
|
-
});
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
295
|
-
// Tool-Calling Enhancement Tests (≥8)
|
|
296
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
297
|
-
|
|
298
|
-
describe('chatWithTools — Enhanced Error Handling', () => {
|
|
299
|
-
let registry: ToolRegistry;
|
|
300
|
-
|
|
301
|
-
beforeEach(() => {
|
|
302
|
-
registry = new ToolRegistry();
|
|
303
|
-
registry.register(
|
|
304
|
-
{ name: 'get_weather', description: 'Get weather', parameters: {} },
|
|
305
|
-
async (args) => JSON.stringify({ temp: 22, city: args.city }),
|
|
306
|
-
);
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it('should invoke onToolError callback when a tool fails', async () => {
|
|
310
|
-
registry.register(
|
|
311
|
-
{ name: 'bad_tool', description: 'Fails', parameters: {} },
|
|
312
|
-
async () => { throw new Error('boom'); },
|
|
313
|
-
);
|
|
314
|
-
|
|
315
|
-
const onToolError = vi.fn().mockReturnValue('continue');
|
|
316
|
-
const adapter = createMockAdapter([
|
|
317
|
-
{ content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'bad_tool', input: {} }] },
|
|
318
|
-
{ content: 'Recovered' },
|
|
319
|
-
]);
|
|
320
|
-
|
|
321
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
322
|
-
const result = await service.chatWithTools(
|
|
323
|
-
[{ role: 'user', content: 'Use tool' }],
|
|
324
|
-
{ onToolError },
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
expect(onToolError).toHaveBeenCalledTimes(1);
|
|
328
|
-
expect(onToolError).toHaveBeenCalledWith(
|
|
329
|
-
expect.objectContaining({ toolName: 'bad_tool' }),
|
|
330
|
-
'boom',
|
|
331
|
-
);
|
|
332
|
-
expect(result.content).toBe('Recovered');
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
it('should abort the tool-call loop when onToolError returns abort', async () => {
|
|
336
|
-
registry.register(
|
|
337
|
-
{ name: 'abort_tool', description: 'Abort', parameters: {} },
|
|
338
|
-
async () => { throw new Error('critical failure'); },
|
|
339
|
-
);
|
|
340
|
-
|
|
341
|
-
const adapter = createMockAdapter([
|
|
342
|
-
{ content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'abort_tool', input: {} }] },
|
|
343
|
-
// This would be the forced-final call
|
|
344
|
-
{ content: 'Aborted cleanly' },
|
|
345
|
-
]);
|
|
346
|
-
|
|
347
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
348
|
-
const result = await service.chatWithTools(
|
|
349
|
-
[{ role: 'user', content: 'Critical' }],
|
|
350
|
-
{ onToolError: () => 'abort' },
|
|
351
|
-
);
|
|
352
|
-
|
|
353
|
-
// Should have called chat twice: once for the tool call, once for forced final
|
|
354
|
-
expect(adapter.chat).toHaveBeenCalledTimes(2);
|
|
355
|
-
expect(result.content).toBe('Aborted cleanly');
|
|
356
|
-
|
|
357
|
-
// Should log the abort-specific message, NOT the max-iterations message
|
|
358
|
-
expect(silentLogger.warn).toHaveBeenCalledWith(
|
|
359
|
-
'[AI] chatWithTools aborted by onToolError callback',
|
|
360
|
-
expect.objectContaining({ toolErrors: expect.any(Array) }),
|
|
361
|
-
);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
it('should not pass onToolError to adapter options', async () => {
|
|
365
|
-
const adapter = createMockAdapter([{ content: 'ok' }]);
|
|
366
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
367
|
-
|
|
368
|
-
await service.chatWithTools(
|
|
369
|
-
[{ role: 'user', content: 'test' }],
|
|
370
|
-
{ onToolError: () => 'continue', model: 'gpt-4' },
|
|
371
|
-
);
|
|
372
|
-
|
|
373
|
-
const options = (adapter.chat as any).mock.calls[0][1];
|
|
374
|
-
expect(options).not.toHaveProperty('onToolError');
|
|
375
|
-
expect(options.model).toBe('gpt-4');
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
it('should continue by default when tool error and no onToolError callback', async () => {
|
|
379
|
-
registry.register(
|
|
380
|
-
{ name: 'fail_tool', description: 'Fails', parameters: {} },
|
|
381
|
-
async () => { throw new Error('oops'); },
|
|
382
|
-
);
|
|
383
|
-
|
|
384
|
-
const adapter = createMockAdapter([
|
|
385
|
-
{ content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'fail_tool', input: {} }] },
|
|
386
|
-
{ content: 'Error was fed back to model' },
|
|
387
|
-
]);
|
|
388
|
-
|
|
389
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
390
|
-
const result = await service.chatWithTools([{ role: 'user', content: 'test' }]);
|
|
391
|
-
|
|
392
|
-
expect(adapter.chat).toHaveBeenCalledTimes(2);
|
|
393
|
-
expect(result.content).toBe('Error was fed back to model');
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
it('should track tool errors and log them on max iterations', async () => {
|
|
397
|
-
registry.register(
|
|
398
|
-
{ name: 'flaky_tool', description: 'Flaky', parameters: {} },
|
|
399
|
-
async () => { throw new Error('flaky'); },
|
|
400
|
-
);
|
|
401
|
-
|
|
402
|
-
const infiniteToolCall: AIResult = {
|
|
403
|
-
content: '',
|
|
404
|
-
toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'flaky_tool', input: {} }],
|
|
405
|
-
};
|
|
406
|
-
const adapter = createMockAdapter(
|
|
407
|
-
Array(2).fill(infiniteToolCall).concat([{ content: 'Forced' }]),
|
|
408
|
-
);
|
|
409
|
-
|
|
410
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
411
|
-
const result = await service.chatWithTools(
|
|
412
|
-
[{ role: 'user', content: 'loop' }],
|
|
413
|
-
{ maxIterations: 2 },
|
|
414
|
-
);
|
|
415
|
-
|
|
416
|
-
// Should warn about max iterations with tool errors
|
|
417
|
-
expect(silentLogger.warn).toHaveBeenCalledWith(
|
|
418
|
-
'[AI] chatWithTools max iterations reached, forcing final response',
|
|
419
|
-
expect.objectContaining({ toolErrors: expect.any(Array) }),
|
|
420
|
-
);
|
|
421
|
-
expect(result.content).toBe('Forced');
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
it('should handle mixed success and error tool calls in one round', async () => {
|
|
425
|
-
registry.register(
|
|
426
|
-
{ name: 'bad_tool', description: 'Bad', parameters: {} },
|
|
427
|
-
async () => { throw new Error('fail'); },
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
const adapter = createMockAdapter([
|
|
431
|
-
{
|
|
432
|
-
content: '',
|
|
433
|
-
toolCalls: [
|
|
434
|
-
{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'get_weather', input: { city: 'NYC' } },
|
|
435
|
-
{ type: 'tool-call' as const, toolCallId: 'c2', toolName: 'bad_tool', input: {} },
|
|
436
|
-
],
|
|
437
|
-
},
|
|
438
|
-
{ content: 'Weather ok, tool failed' },
|
|
439
|
-
]);
|
|
440
|
-
|
|
441
|
-
const onToolError = vi.fn().mockReturnValue('continue');
|
|
442
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
443
|
-
const result = await service.chatWithTools(
|
|
444
|
-
[{ role: 'user', content: 'Both tools' }],
|
|
445
|
-
{ onToolError },
|
|
446
|
-
);
|
|
447
|
-
|
|
448
|
-
// Only called for the failing tool
|
|
449
|
-
expect(onToolError).toHaveBeenCalledTimes(1);
|
|
450
|
-
expect(onToolError).toHaveBeenCalledWith(
|
|
451
|
-
expect.objectContaining({ toolName: 'bad_tool' }),
|
|
452
|
-
'fail',
|
|
453
|
-
);
|
|
454
|
-
|
|
455
|
-
// Both tool results fed back
|
|
456
|
-
const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[];
|
|
457
|
-
const toolMessages = secondCallMessages.filter(m => m.role === 'tool');
|
|
458
|
-
expect(toolMessages).toHaveLength(2);
|
|
459
|
-
expect(result.content).toBe('Weather ok, tool failed');
|
|
460
|
-
});
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
describe('streamChatWithTools', () => {
|
|
464
|
-
let registry: ToolRegistry;
|
|
465
|
-
|
|
466
|
-
beforeEach(() => {
|
|
467
|
-
registry = new ToolRegistry();
|
|
468
|
-
registry.register(
|
|
469
|
-
{ name: 'get_weather', description: 'Get weather', parameters: {} },
|
|
470
|
-
async (args) => JSON.stringify({ temp: 22, city: args.city }),
|
|
471
|
-
);
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
it('should stream final response when no tool calls', async () => {
|
|
475
|
-
const adapter: LLMAdapter = {
|
|
476
|
-
name: 'mock-stream',
|
|
477
|
-
chat: vi.fn(async () => ({ content: 'Hello!' })),
|
|
478
|
-
complete: vi.fn(async () => ({ content: '' })),
|
|
479
|
-
};
|
|
480
|
-
|
|
481
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
482
|
-
const events: TextStreamPart<ToolSet>[] = [];
|
|
483
|
-
for await (const event of service.streamChatWithTools([{ role: 'user', content: 'Hi' }])) {
|
|
484
|
-
events.push(event);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Should emit the probed result as text-delta + finish (no double model call)
|
|
488
|
-
expect(events).toHaveLength(2);
|
|
489
|
-
expect(events[0].type).toBe('text-delta');
|
|
490
|
-
expect((events[0] as any).text).toBe('Hello!');
|
|
491
|
-
expect(events[1].type).toBe('finish');
|
|
492
|
-
expect(adapter.chat).toHaveBeenCalledTimes(1);
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
it('should emit tool-call events during tool resolution', async () => {
|
|
496
|
-
const toolCall: ToolCallPart = {
|
|
497
|
-
type: 'tool-call',
|
|
498
|
-
toolCallId: 'call_1',
|
|
499
|
-
toolName: 'get_weather',
|
|
500
|
-
input: { city: 'Tokyo' },
|
|
501
|
-
};
|
|
502
|
-
|
|
503
|
-
let chatCallIndex = 0;
|
|
504
|
-
const adapter: LLMAdapter = {
|
|
505
|
-
name: 'mock-stream',
|
|
506
|
-
chat: vi.fn(async () => {
|
|
507
|
-
chatCallIndex++;
|
|
508
|
-
if (chatCallIndex === 1) {
|
|
509
|
-
return { content: '', toolCalls: [toolCall] };
|
|
510
|
-
}
|
|
511
|
-
return { content: 'Tokyo is 22°C' };
|
|
512
|
-
}),
|
|
513
|
-
complete: vi.fn(async () => ({ content: '' })),
|
|
514
|
-
};
|
|
515
|
-
|
|
516
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
517
|
-
const events: TextStreamPart<ToolSet>[] = [];
|
|
518
|
-
for await (const event of service.streamChatWithTools(
|
|
519
|
-
[{ role: 'user', content: 'Weather in Tokyo?' }],
|
|
520
|
-
)) {
|
|
521
|
-
events.push(event);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Should have tool-call + tool-result events followed by text-delta + finish
|
|
525
|
-
const toolCallEvents = events.filter(e => e.type === 'tool-call');
|
|
526
|
-
expect(toolCallEvents).toHaveLength(1);
|
|
527
|
-
expect((toolCallEvents[0] as any).toolName).toBe('get_weather');
|
|
528
|
-
|
|
529
|
-
const toolResultEvents = events.filter(e => e.type === 'tool-result');
|
|
530
|
-
expect(toolResultEvents).toHaveLength(1);
|
|
531
|
-
expect((toolResultEvents[0] as any).toolCallId).toBe('call_1');
|
|
532
|
-
expect((toolResultEvents[0] as any).toolName).toBe('get_weather');
|
|
533
|
-
|
|
534
|
-
const finishEvent = events.find(e => e.type === 'finish');
|
|
535
|
-
expect(finishEvent).toBeDefined();
|
|
536
|
-
expect(adapter.chat).toHaveBeenCalledTimes(2);
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
it('should yield tool-result events with tool output', async () => {
|
|
540
|
-
const toolCall: ToolCallPart = {
|
|
541
|
-
type: 'tool-call',
|
|
542
|
-
toolCallId: 'call_weather',
|
|
543
|
-
toolName: 'get_weather',
|
|
544
|
-
input: { city: 'Paris' },
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
let chatCallIndex = 0;
|
|
548
|
-
const adapter: LLMAdapter = {
|
|
549
|
-
name: 'mock-stream',
|
|
550
|
-
chat: vi.fn(async () => {
|
|
551
|
-
chatCallIndex++;
|
|
552
|
-
if (chatCallIndex === 1) {
|
|
553
|
-
return { content: '', toolCalls: [toolCall] };
|
|
554
|
-
}
|
|
555
|
-
return { content: 'Paris is 22°C' };
|
|
556
|
-
}),
|
|
557
|
-
complete: vi.fn(async () => ({ content: '' })),
|
|
558
|
-
};
|
|
559
|
-
|
|
560
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
561
|
-
const events: TextStreamPart<ToolSet>[] = [];
|
|
562
|
-
for await (const event of service.streamChatWithTools(
|
|
563
|
-
[{ role: 'user', content: 'Weather in Paris?' }],
|
|
564
|
-
)) {
|
|
565
|
-
events.push(event);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Verify the tool-result contains actual tool output
|
|
569
|
-
const toolResultEvents = events.filter(e => e.type === 'tool-result');
|
|
570
|
-
expect(toolResultEvents).toHaveLength(1);
|
|
571
|
-
const toolResult = toolResultEvents[0] as any;
|
|
572
|
-
expect(toolResult.toolCallId).toBe('call_weather');
|
|
573
|
-
expect(toolResult.toolName).toBe('get_weather');
|
|
574
|
-
expect(toolResult.output).toEqual({ type: 'text', value: JSON.stringify({ temp: 22, city: 'Paris' }) });
|
|
575
|
-
|
|
576
|
-
// Verify order: tool-call comes before tool-result
|
|
577
|
-
const toolCallIdx = events.findIndex(e => e.type === 'tool-call');
|
|
578
|
-
const toolResultIdx = events.findIndex(e => e.type === 'tool-result');
|
|
579
|
-
expect(toolCallIdx).toBeGreaterThanOrEqual(0);
|
|
580
|
-
expect(toolResultIdx).toBeGreaterThanOrEqual(0);
|
|
581
|
-
expect(toolCallIdx).toBeLessThan(toolResultIdx);
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
it('should fall back to non-streaming when adapter has no streamChat', async () => {
|
|
585
|
-
const adapter: LLMAdapter = {
|
|
586
|
-
name: 'no-stream',
|
|
587
|
-
chat: vi.fn(async () => ({ content: 'Fallback response' })),
|
|
588
|
-
complete: vi.fn(async () => ({ content: '' })),
|
|
589
|
-
// no streamChat
|
|
590
|
-
};
|
|
591
|
-
|
|
592
|
-
const emptyRegistry = new ToolRegistry();
|
|
593
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: emptyRegistry });
|
|
594
|
-
const events: TextStreamPart<ToolSet>[] = [];
|
|
595
|
-
for await (const event of service.streamChatWithTools(
|
|
596
|
-
[{ role: 'user', content: 'Hi' }],
|
|
597
|
-
)) {
|
|
598
|
-
events.push(event);
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
expect(events).toHaveLength(2);
|
|
602
|
-
expect(events[0].type).toBe('text-delta');
|
|
603
|
-
expect((events[0] as any).text).toBe('Fallback response');
|
|
604
|
-
expect(events[1].type).toBe('finish');
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
it('should respect maxIterations in streaming tool loop', async () => {
|
|
608
|
-
const infiniteToolCall: AIResult = {
|
|
609
|
-
content: '',
|
|
610
|
-
toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'get_weather', input: { city: 'X' } }],
|
|
611
|
-
};
|
|
612
|
-
|
|
613
|
-
let callIndex = 0;
|
|
614
|
-
const adapter: LLMAdapter = {
|
|
615
|
-
name: 'mock',
|
|
616
|
-
chat: vi.fn(async () => {
|
|
617
|
-
callIndex++;
|
|
618
|
-
if (callIndex <= 5) return infiniteToolCall;
|
|
619
|
-
return { content: 'Forced stop' };
|
|
620
|
-
}),
|
|
621
|
-
complete: vi.fn(async () => ({ content: '' })),
|
|
622
|
-
async *streamChat() {
|
|
623
|
-
yield { type: 'text-delta' as const, id: '1', text: 'Forced stop' } as TextStreamPart<ToolSet>;
|
|
624
|
-
yield { type: 'finish' as const, finishReason: 'stop' as const, totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, rawFinishReason: 'stop' } as unknown as TextStreamPart<ToolSet>;
|
|
625
|
-
},
|
|
626
|
-
};
|
|
627
|
-
|
|
628
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
629
|
-
const events: TextStreamPart<ToolSet>[] = [];
|
|
630
|
-
for await (const event of service.streamChatWithTools(
|
|
631
|
-
[{ role: 'user', content: 'Loop' }],
|
|
632
|
-
{ maxIterations: 2 },
|
|
633
|
-
)) {
|
|
634
|
-
events.push(event);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// 2 iterations of tool calls + 1 forced final call (all via adapter.chat)
|
|
638
|
-
expect(adapter.chat).toHaveBeenCalledTimes(3);
|
|
639
|
-
expect(events.some(e => e.type === 'finish')).toBe(true);
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
it('should abort streaming tool loop on onToolError returning abort', async () => {
|
|
643
|
-
registry.register(
|
|
644
|
-
{ name: 'critical_fail', description: 'Fails critically', parameters: {} },
|
|
645
|
-
async () => { throw new Error('critical'); },
|
|
646
|
-
);
|
|
647
|
-
|
|
648
|
-
let chatCallIndex = 0;
|
|
649
|
-
const adapter: LLMAdapter = {
|
|
650
|
-
name: 'mock-stream',
|
|
651
|
-
chat: vi.fn(async () => {
|
|
652
|
-
chatCallIndex++;
|
|
653
|
-
if (chatCallIndex === 1) {
|
|
654
|
-
return {
|
|
655
|
-
content: '',
|
|
656
|
-
toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'critical_fail', input: {} }],
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
return { content: 'Aborted' };
|
|
660
|
-
}),
|
|
661
|
-
complete: vi.fn(async () => ({ content: '' })),
|
|
662
|
-
};
|
|
663
|
-
|
|
664
|
-
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
665
|
-
const events: TextStreamPart<ToolSet>[] = [];
|
|
666
|
-
for await (const event of service.streamChatWithTools(
|
|
667
|
-
[{ role: 'user', content: 'Critical' }],
|
|
668
|
-
{ onToolError: () => 'abort' },
|
|
669
|
-
)) {
|
|
670
|
-
events.push(event);
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// Should have the tool-call event + forced final via adapter.chat
|
|
674
|
-
expect(events.some(e => e.type === 'finish')).toBe(true);
|
|
675
|
-
expect(adapter.chat).toHaveBeenCalledTimes(2);
|
|
676
|
-
});
|
|
677
|
-
});
|