@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.
Files changed (52) hide show
  1. package/README.md +293 -0
  2. package/dist/index.cjs +1176 -135
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +1225 -430
  5. package/dist/index.d.ts +1225 -430
  6. package/dist/index.js +1160 -128
  7. package/dist/index.js.map +1 -1
  8. package/package.json +35 -8
  9. package/.turbo/turbo-build.log +0 -22
  10. package/CHANGELOG.md +0 -53
  11. package/src/__tests__/ai-service.test.ts +0 -964
  12. package/src/__tests__/auth-and-toolcalling.test.ts +0 -677
  13. package/src/__tests__/chatbot-features.test.ts +0 -1116
  14. package/src/__tests__/metadata-tools.test.ts +0 -970
  15. package/src/__tests__/objectql-conversation-service.test.ts +0 -382
  16. package/src/__tests__/tool-routes.test.ts +0 -191
  17. package/src/__tests__/vercel-stream-encoder.test.ts +0 -310
  18. package/src/adapters/index.ts +0 -6
  19. package/src/adapters/memory-adapter.ts +0 -72
  20. package/src/adapters/types.ts +0 -3
  21. package/src/adapters/vercel-adapter.ts +0 -148
  22. package/src/agent-runtime.ts +0 -154
  23. package/src/agents/data-chat-agent.ts +0 -79
  24. package/src/agents/index.ts +0 -4
  25. package/src/agents/metadata-assistant-agent.ts +0 -87
  26. package/src/ai-service.ts +0 -364
  27. package/src/conversation/in-memory-conversation-service.ts +0 -103
  28. package/src/conversation/index.ts +0 -4
  29. package/src/conversation/objectql-conversation-service.ts +0 -301
  30. package/src/index.ts +0 -60
  31. package/src/objects/ai-conversation.object.ts +0 -86
  32. package/src/objects/ai-message.object.ts +0 -86
  33. package/src/objects/index.ts +0 -10
  34. package/src/plugin.ts +0 -391
  35. package/src/routes/agent-routes.ts +0 -190
  36. package/src/routes/ai-routes.ts +0 -439
  37. package/src/routes/index.ts +0 -5
  38. package/src/routes/message-utils.ts +0 -90
  39. package/src/routes/tool-routes.ts +0 -142
  40. package/src/stream/index.ts +0 -3
  41. package/src/stream/vercel-stream-encoder.ts +0 -153
  42. package/src/tools/add-field.tool.ts +0 -70
  43. package/src/tools/create-object.tool.ts +0 -66
  44. package/src/tools/data-tools.ts +0 -293
  45. package/src/tools/delete-field.tool.ts +0 -38
  46. package/src/tools/describe-object.tool.ts +0 -31
  47. package/src/tools/index.ts +0 -18
  48. package/src/tools/list-objects.tool.ts +0 -34
  49. package/src/tools/metadata-tools.ts +0 -430
  50. package/src/tools/modify-field.tool.ts +0 -44
  51. package/src/tools/tool-registry.ts +0 -132
  52. 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
- });