@objectstack/service-ai 4.0.1 → 4.0.2

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 (39) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +9 -0
  3. package/dist/index.cjs +1120 -66
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +316 -78
  6. package/dist/index.d.ts +316 -78
  7. package/dist/index.js +1105 -63
  8. package/dist/index.js.map +1 -1
  9. package/package.json +26 -4
  10. package/src/__tests__/ai-service.test.ts +248 -27
  11. package/src/__tests__/auth-and-toolcalling.test.ts +30 -28
  12. package/src/__tests__/chatbot-features.test.ts +229 -82
  13. package/src/__tests__/metadata-tools.test.ts +964 -0
  14. package/src/__tests__/objectql-conversation-service.test.ts +34 -16
  15. package/src/__tests__/vercel-stream-encoder.test.ts +263 -0
  16. package/src/adapters/index.ts +2 -0
  17. package/src/adapters/memory-adapter.ts +17 -9
  18. package/src/adapters/vercel-adapter.ts +148 -0
  19. package/src/agent-runtime.ts +27 -3
  20. package/src/agents/index.ts +1 -0
  21. package/src/agents/metadata-assistant-agent.ts +87 -0
  22. package/src/ai-service.ts +68 -36
  23. package/src/conversation/in-memory-conversation-service.ts +2 -2
  24. package/src/conversation/objectql-conversation-service.ts +67 -18
  25. package/src/index.ts +21 -2
  26. package/src/plugin.ts +166 -9
  27. package/src/routes/agent-routes.ts +26 -3
  28. package/src/routes/ai-routes.ts +156 -13
  29. package/src/stream/index.ts +3 -0
  30. package/src/stream/vercel-stream-encoder.ts +129 -0
  31. package/src/tools/add-field.tool.ts +70 -0
  32. package/src/tools/create-object.tool.ts +66 -0
  33. package/src/tools/delete-field.tool.ts +38 -0
  34. package/src/tools/describe-metadata-object.tool.ts +32 -0
  35. package/src/tools/index.ts +12 -1
  36. package/src/tools/list-metadata-objects.tool.ts +34 -0
  37. package/src/tools/metadata-tools.ts +430 -0
  38. package/src/tools/modify-field.tool.ts +44 -0
  39. package/src/tools/tool-registry.ts +32 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/service-ai",
3
- "version": "4.0.1",
3
+ "version": "4.0.2",
4
4
  "license": "Apache-2.0",
5
5
  "description": "AI Service for ObjectStack — implements IAIService with LLM adapter layer, conversation management, tool registry, and REST/SSE routes",
6
6
  "type": "module",
@@ -14,11 +14,33 @@
14
14
  }
15
15
  },
16
16
  "dependencies": {
17
- "@objectstack/core": "4.0.1",
18
- "@objectstack/spec": "4.0.1"
17
+ "@ai-sdk/provider": "^3.0.8",
18
+ "ai": "^6.0.146",
19
+ "@objectstack/core": "4.0.2",
20
+ "@objectstack/spec": "4.0.2"
21
+ },
22
+ "peerDependencies": {
23
+ "@ai-sdk/anthropic": "^3.0.0",
24
+ "@ai-sdk/gateway": "^3.0.0",
25
+ "@ai-sdk/google": "^3.0.0",
26
+ "@ai-sdk/openai": "^3.0.0"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "@ai-sdk/anthropic": {
30
+ "optional": true
31
+ },
32
+ "@ai-sdk/gateway": {
33
+ "optional": true
34
+ },
35
+ "@ai-sdk/google": {
36
+ "optional": true
37
+ },
38
+ "@ai-sdk/openai": {
39
+ "optional": true
40
+ }
19
41
  },
20
42
  "devDependencies": {
21
- "@types/node": "^25.5.0",
43
+ "@types/node": "^25.5.2",
22
44
  "typescript": "^6.0.2",
23
45
  "vitest": "^4.1.2"
24
46
  },
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import { describe, it, expect, vi, beforeEach } from 'vitest';
4
- import type { AIMessage, IAIService, AIStreamEvent } from '@objectstack/spec/contracts';
4
+ import type { ModelMessage, IAIService, TextStreamPart, ToolSet } from '@objectstack/spec/contracts';
5
5
  import { AIService } from '../ai-service.js';
6
6
  import { MemoryLLMAdapter } from '../adapters/memory-adapter.js';
7
7
  import { ToolRegistry } from '../tools/tool-registry.js';
@@ -35,7 +35,7 @@ describe('MemoryLLMAdapter', () => {
35
35
  });
36
36
 
37
37
  it('should echo the last user message in chat()', async () => {
38
- const messages: AIMessage[] = [
38
+ const messages: ModelMessage[] = [
39
39
  { role: 'system', content: 'You are helpful.' },
40
40
  { role: 'user', content: 'Hello AI' },
41
41
  ];
@@ -46,7 +46,7 @@ describe('MemoryLLMAdapter', () => {
46
46
  });
47
47
 
48
48
  it('should handle no user message in chat()', async () => {
49
- const messages: AIMessage[] = [{ role: 'system', content: 'System only' }];
49
+ const messages: ModelMessage[] = [{ role: 'system', content: 'System only' }];
50
50
  const result = await adapter.chat(messages);
51
51
  expect(result.content).toBe('[memory] (no user message)');
52
52
  });
@@ -57,8 +57,8 @@ describe('MemoryLLMAdapter', () => {
57
57
  });
58
58
 
59
59
  it('should stream word-by-word in streamChat()', async () => {
60
- const messages: AIMessage[] = [{ role: 'user', content: 'Hi there' }];
61
- const events: AIStreamEvent[] = [];
60
+ const messages: ModelMessage[] = [{ role: 'user', content: 'Hi there' }];
61
+ const events: TextStreamPart<ToolSet>[] = [];
62
62
  for await (const event of adapter.streamChat(messages)) {
63
63
  events.push(event);
64
64
  }
@@ -113,24 +113,26 @@ describe('ToolRegistry', () => {
113
113
  );
114
114
 
115
115
  const result = await registry.execute({
116
- id: 'call_1',
117
- name: 'add',
118
- arguments: JSON.stringify({ a: 3, b: 4 }),
116
+ type: 'tool-call',
117
+ toolCallId: 'call_1',
118
+ toolName: 'add',
119
+ input: { a: 3, b: 4 },
119
120
  });
120
121
 
121
122
  expect(result.toolCallId).toBe('call_1');
122
- expect(result.content).toBe('7');
123
+ expect(result.output).toEqual({ type: 'text', value: '7' });
123
124
  expect(result.isError).toBeUndefined();
124
125
  });
125
126
 
126
127
  it('should return error for unknown tool', async () => {
127
128
  const result = await registry.execute({
128
- id: 'call_x',
129
- name: 'unknown',
130
- arguments: '{}',
129
+ type: 'tool-call',
130
+ toolCallId: 'call_x',
131
+ toolName: 'unknown',
132
+ input: {},
131
133
  });
132
134
  expect(result.isError).toBe(true);
133
- expect(result.content).toContain('not registered');
135
+ expect(result.output).toEqual(expect.objectContaining({ type: 'text', value: expect.stringContaining('not registered') }));
134
136
  });
135
137
 
136
138
  it('should return error on handler failure', async () => {
@@ -140,12 +142,13 @@ describe('ToolRegistry', () => {
140
142
  );
141
143
 
142
144
  const result = await registry.execute({
143
- id: 'call_f',
144
- name: 'fail_tool',
145
- arguments: '{}',
145
+ type: 'tool-call',
146
+ toolCallId: 'call_f',
147
+ toolName: 'fail_tool',
148
+ input: {},
146
149
  });
147
150
  expect(result.isError).toBe(true);
148
- expect(result.content).toBe('boom');
151
+ expect(result.output).toEqual({ type: 'text', value: 'boom' });
149
152
  });
150
153
 
151
154
  it('should execute multiple tool calls in parallel', async () => {
@@ -155,13 +158,13 @@ describe('ToolRegistry', () => {
155
158
  );
156
159
 
157
160
  const results = await registry.executeAll([
158
- { id: 'c1', name: 'echo', arguments: '{"msg":"a"}' },
159
- { id: 'c2', name: 'echo', arguments: '{"msg":"b"}' },
161
+ { type: 'tool-call', toolCallId: 'c1', toolName: 'echo', input: { msg: 'a' } },
162
+ { type: 'tool-call', toolCallId: 'c2', toolName: 'echo', input: { msg: 'b' } },
160
163
  ]);
161
164
 
162
165
  expect(results).toHaveLength(2);
163
- expect(results[0].content).toBe('a');
164
- expect(results[1].content).toBe('b');
166
+ expect(results[0].output).toEqual({ type: 'text', value: 'a' });
167
+ expect(results[1].output).toEqual({ type: 'text', value: 'b' });
165
168
  });
166
169
 
167
170
  it('should return all definitions', () => {
@@ -272,7 +275,7 @@ describe('AIService', () => {
272
275
 
273
276
  it('should stream via adapter.streamChat()', async () => {
274
277
  const service = new AIService({ logger: silentLogger });
275
- const events: AIStreamEvent[] = [];
278
+ const events: TextStreamPart<ToolSet>[] = [];
276
279
  for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
277
280
  events.push(event);
278
281
  }
@@ -289,14 +292,14 @@ describe('AIService', () => {
289
292
  };
290
293
  const service = new AIService({ adapter, logger: silentLogger });
291
294
 
292
- const events: AIStreamEvent[] = [];
295
+ const events: TextStreamPart<ToolSet>[] = [];
293
296
  for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
294
297
  events.push(event);
295
298
  }
296
299
 
297
300
  expect(events).toHaveLength(2);
298
301
  expect(events[0].type).toBe('text-delta');
299
- expect(events[0].textDelta).toBe('response');
302
+ expect(events[0].type === 'text-delta' && events[0].text).toBe('response');
300
303
  expect(events[1].type).toBe('finish');
301
304
  });
302
305
 
@@ -379,7 +382,19 @@ describe('AI Routes', () => {
379
382
  expect(paths).toContain('DELETE /api/v1/ai/conversations/:id');
380
383
  });
381
384
 
382
- it('POST /api/v1/ai/chat should return chat result', async () => {
385
+ it('POST /api/v1/ai/chat should return JSON result when stream=false', async () => {
386
+ const routes = buildAIRoutes(service, service.conversationService, silentLogger);
387
+ const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
388
+
389
+ const response = await chatRoute.handler({
390
+ body: { messages: [{ role: 'user', content: 'Hi' }], stream: false },
391
+ });
392
+
393
+ expect(response.status).toBe(200);
394
+ expect((response.body as any).content).toBe('[memory] Hi');
395
+ });
396
+
397
+ it('POST /api/v1/ai/chat should default to Vercel Data Stream mode', async () => {
383
398
  const routes = buildAIRoutes(service, service.conversationService, silentLogger);
384
399
  const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
385
400
 
@@ -387,10 +402,86 @@ describe('AI Routes', () => {
387
402
  body: { messages: [{ role: 'user', content: 'Hi' }] },
388
403
  });
389
404
 
405
+ expect(response.status).toBe(200);
406
+ expect(response.stream).toBe(true);
407
+ expect(response.vercelDataStream).toBe(true);
408
+ expect(response.events).toBeDefined();
409
+
410
+ // Consume the Vercel Data Stream events
411
+ const events: unknown[] = [];
412
+ for await (const event of response.events!) {
413
+ events.push(event);
414
+ }
415
+ expect(events.length).toBeGreaterThan(0);
416
+ });
417
+
418
+ it('POST /api/v1/ai/chat should prepend systemPrompt as system message', async () => {
419
+ const routes = buildAIRoutes(service, service.conversationService, silentLogger);
420
+ const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
421
+
422
+ const response = await chatRoute.handler({
423
+ body: {
424
+ messages: [{ role: 'user', content: 'Hello' }],
425
+ system: 'You are a helpful assistant',
426
+ stream: false,
427
+ },
428
+ });
429
+
430
+ expect(response.status).toBe(200);
431
+ // MemoryLLMAdapter echoes the last user message
432
+ expect((response.body as any).content).toBe('[memory] Hello');
433
+ });
434
+
435
+ it('POST /api/v1/ai/chat should accept deprecated systemPrompt field', async () => {
436
+ const routes = buildAIRoutes(service, service.conversationService, silentLogger);
437
+ const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
438
+
439
+ const response = await chatRoute.handler({
440
+ body: {
441
+ messages: [{ role: 'user', content: 'Hi' }],
442
+ systemPrompt: 'Be concise',
443
+ stream: false,
444
+ },
445
+ });
446
+
390
447
  expect(response.status).toBe(200);
391
448
  expect((response.body as any).content).toBe('[memory] Hi');
392
449
  });
393
450
 
451
+ it('POST /api/v1/ai/chat should accept flat Vercel-style fields (model, temperature)', async () => {
452
+ const routes = buildAIRoutes(service, service.conversationService, silentLogger);
453
+ const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
454
+
455
+ const response = await chatRoute.handler({
456
+ body: {
457
+ messages: [{ role: 'user', content: 'Hi' }],
458
+ model: 'gpt-4o',
459
+ temperature: 0.5,
460
+ stream: false,
461
+ },
462
+ });
463
+
464
+ expect(response.status).toBe(200);
465
+ // MemoryLLMAdapter uses the model from options when provided
466
+ expect((response.body as any).model).toBe('gpt-4o');
467
+ });
468
+
469
+ it('POST /api/v1/ai/chat should accept array content (Vercel multi-part)', async () => {
470
+ const routes = buildAIRoutes(service, service.conversationService, silentLogger);
471
+ const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
472
+
473
+ const response = await chatRoute.handler({
474
+ body: {
475
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }],
476
+ stream: false,
477
+ },
478
+ });
479
+
480
+ // MemoryLLMAdapter falls back to "(complex content)" for non-string
481
+ expect(response.status).toBe(200);
482
+ expect((response.body as any).content).toBe('[memory] (complex content)');
483
+ });
484
+
394
485
  it('POST /api/v1/ai/chat should return 400 without messages', async () => {
395
486
  const routes = buildAIRoutes(service, service.conversationService, silentLogger);
396
487
  const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
@@ -528,16 +619,30 @@ describe('AI Routes', () => {
528
619
  expect((response.body as any).error).toContain('message.role');
529
620
  });
530
621
 
531
- it('POST /api/v1/ai/chat should return 400 for messages with non-string content', async () => {
622
+ it('POST /api/v1/ai/chat should return 400 for messages with non-string/non-array content', async () => {
532
623
  const routes = buildAIRoutes(service, service.conversationService, silentLogger);
533
624
  const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
534
625
 
626
+ // Numeric content should be rejected
535
627
  const response = await chatRoute.handler({
536
628
  body: { messages: [{ role: 'user', content: 123 }] },
537
629
  });
538
-
539
630
  expect(response.status).toBe(400);
540
631
  expect((response.body as any).error).toContain('content');
632
+
633
+ // Object content (not an array) should be rejected
634
+ const response2 = await chatRoute.handler({
635
+ body: { messages: [{ role: 'user', content: { nested: true } }] },
636
+ });
637
+ expect(response2.status).toBe(400);
638
+ expect((response2.body as any).error).toContain('content');
639
+
640
+ // Boolean content should be rejected
641
+ const response3 = await chatRoute.handler({
642
+ body: { messages: [{ role: 'user', content: true }] },
643
+ });
644
+ expect(response3.status).toBe(400);
645
+ expect((response3.body as any).error).toContain('content');
541
646
  });
542
647
 
543
648
  it('POST /api/v1/ai/conversations/:id/messages should return 400 for invalid role', async () => {
@@ -617,6 +722,7 @@ describe('AI Routes', () => {
617
722
  { role: 'assistant', content: '' },
618
723
  { role: 'tool', content: '{"temp": 22}', toolCallId: 'call_1' },
619
724
  ],
725
+ stream: false,
620
726
  },
621
727
  });
622
728
 
@@ -633,6 +739,9 @@ describe('AIServicePlugin', () => {
633
739
  const services = new Map<string, unknown>();
634
740
  const hooks = new Map<string, Function[]>();
635
741
 
742
+ // Pre-register manifest service
743
+ services.set('manifest', { register: vi.fn() });
744
+
636
745
  return {
637
746
  registerService: vi.fn((name: string, service: unknown) => services.set(name, service)),
638
747
  replaceService: vi.fn((name: string, service: unknown) => services.set(name, service)),
@@ -728,4 +837,116 @@ describe('AIServicePlugin', () => {
728
837
 
729
838
  expect(ctx.hook).toHaveBeenCalledWith('ai:beforeChat', expect.any(Function));
730
839
  });
840
+
841
+ // ── LLM Provider Auto-Detection ─────────────────────────────────
842
+
843
+ it('should use MemoryLLMAdapter when no env vars are set', async () => {
844
+ const plugin = new AIServicePlugin();
845
+ const ctx = createMockContext();
846
+
847
+ // Ensure no LLM provider env vars are set
848
+ const oldEnv = { ...process.env };
849
+ delete process.env.AI_GATEWAY_MODEL;
850
+ delete process.env.OPENAI_API_KEY;
851
+ delete process.env.ANTHROPIC_API_KEY;
852
+ delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
853
+
854
+ try {
855
+ await plugin.init(ctx);
856
+
857
+ const service = ctx.getService<AIService>('ai');
858
+ expect(service.adapterName).toBe('memory');
859
+
860
+ // Verify warning was logged
861
+ expect(silentLogger.warn).toHaveBeenCalledWith(
862
+ expect.stringContaining('No LLM provider configured')
863
+ );
864
+ } finally {
865
+ // Restore environment
866
+ process.env = oldEnv;
867
+ }
868
+ });
869
+
870
+ it('should fallback to MemoryLLMAdapter when provider SDK is not installed', async () => {
871
+ const plugin = new AIServicePlugin();
872
+ const ctx = createMockContext();
873
+
874
+ const oldEnv = { ...process.env };
875
+ // Set env var, but the SDK won't be available in test environment
876
+ process.env.OPENAI_API_KEY = 'fake-openai-key';
877
+ delete process.env.AI_GATEWAY_MODEL;
878
+ delete process.env.ANTHROPIC_API_KEY;
879
+ delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
880
+
881
+ try {
882
+ await plugin.init(ctx);
883
+
884
+ const service = ctx.getService<AIService>('ai');
885
+ // Should fall back to memory because @ai-sdk/openai is not installed
886
+ expect(service.adapterName).toBe('memory');
887
+
888
+ // Verify warning was logged about SDK load failure
889
+ expect(silentLogger.warn).toHaveBeenCalledWith(
890
+ expect.stringContaining('Failed to load @ai-sdk/openai'),
891
+ expect.objectContaining({ error: expect.any(String) })
892
+ );
893
+
894
+ // Verify warning was logged about final fallback
895
+ expect(silentLogger.warn).toHaveBeenCalledWith(
896
+ expect.stringContaining('No LLM provider configured')
897
+ );
898
+ } finally {
899
+ process.env = oldEnv;
900
+ }
901
+ });
902
+
903
+ it('should prefer explicit adapter over auto-detection', async () => {
904
+ const customAdapter: LLMAdapter = {
905
+ name: 'custom-explicit',
906
+ chat: async () => ({ content: 'explicit' }),
907
+ complete: async () => ({ content: '' }),
908
+ };
909
+
910
+ const plugin = new AIServicePlugin({ adapter: customAdapter });
911
+ const ctx = createMockContext();
912
+
913
+ const oldEnv = { ...process.env };
914
+ process.env.OPENAI_API_KEY = 'fake-openai-key';
915
+
916
+ try {
917
+ await plugin.init(ctx);
918
+
919
+ const service = ctx.getService<AIService>('ai');
920
+ expect(service.adapterName).toBe('custom-explicit');
921
+
922
+ // Verify it logged as explicitly configured
923
+ expect(silentLogger.info).toHaveBeenCalledWith(
924
+ expect.stringContaining('explicitly configured')
925
+ );
926
+ } finally {
927
+ process.env = oldEnv;
928
+ }
929
+ });
930
+
931
+ it('should log adapter selection', async () => {
932
+ const plugin = new AIServicePlugin();
933
+ const ctx = createMockContext();
934
+
935
+ const oldEnv = { ...process.env };
936
+ delete process.env.AI_GATEWAY_MODEL;
937
+ delete process.env.OPENAI_API_KEY;
938
+ delete process.env.ANTHROPIC_API_KEY;
939
+ delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
940
+
941
+ try {
942
+ await plugin.init(ctx);
943
+
944
+ // Verify adapter selection was logged
945
+ expect(silentLogger.info).toHaveBeenCalledWith(
946
+ expect.stringContaining('Using LLM adapter')
947
+ );
948
+ } finally {
949
+ process.env = oldEnv;
950
+ }
951
+ });
731
952
  });
@@ -2,11 +2,12 @@
2
2
 
3
3
  import { describe, it, expect, vi, beforeEach } from 'vitest';
4
4
  import type {
5
- AIMessage,
5
+ ModelMessage,
6
6
  AIResult,
7
7
  AIRequestOptions,
8
- AIStreamEvent,
9
- AIToolCall,
8
+ TextStreamPart,
9
+ ToolSet,
10
+ ToolCallPart,
10
11
  LLMAdapter,
11
12
  } from '@objectstack/spec/contracts';
12
13
  import { AIService } from '../ai-service.js';
@@ -313,7 +314,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
313
314
 
314
315
  const onToolError = vi.fn().mockReturnValue('continue');
315
316
  const adapter = createMockAdapter([
316
- { content: '', toolCalls: [{ id: 'c1', name: 'bad_tool', arguments: '{}' }] },
317
+ { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'bad_tool', input: {} }] },
317
318
  { content: 'Recovered' },
318
319
  ]);
319
320
 
@@ -325,7 +326,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
325
326
 
326
327
  expect(onToolError).toHaveBeenCalledTimes(1);
327
328
  expect(onToolError).toHaveBeenCalledWith(
328
- expect.objectContaining({ name: 'bad_tool' }),
329
+ expect.objectContaining({ toolName: 'bad_tool' }),
329
330
  'boom',
330
331
  );
331
332
  expect(result.content).toBe('Recovered');
@@ -338,7 +339,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
338
339
  );
339
340
 
340
341
  const adapter = createMockAdapter([
341
- { content: '', toolCalls: [{ id: 'c1', name: 'abort_tool', arguments: '{}' }] },
342
+ { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'abort_tool', input: {} }] },
342
343
  // This would be the forced-final call
343
344
  { content: 'Aborted cleanly' },
344
345
  ]);
@@ -381,7 +382,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
381
382
  );
382
383
 
383
384
  const adapter = createMockAdapter([
384
- { content: '', toolCalls: [{ id: 'c1', name: 'fail_tool', arguments: '{}' }] },
385
+ { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'fail_tool', input: {} }] },
385
386
  { content: 'Error was fed back to model' },
386
387
  ]);
387
388
 
@@ -400,7 +401,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
400
401
 
401
402
  const infiniteToolCall: AIResult = {
402
403
  content: '',
403
- toolCalls: [{ id: 'c', name: 'flaky_tool', arguments: '{}' }],
404
+ toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'flaky_tool', input: {} }],
404
405
  };
405
406
  const adapter = createMockAdapter(
406
407
  Array(2).fill(infiniteToolCall).concat([{ content: 'Forced' }]),
@@ -430,8 +431,8 @@ describe('chatWithTools — Enhanced Error Handling', () => {
430
431
  {
431
432
  content: '',
432
433
  toolCalls: [
433
- { id: 'c1', name: 'get_weather', arguments: '{"city":"NYC"}' },
434
- { id: 'c2', name: 'bad_tool', arguments: '{}' },
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: {} },
435
436
  ],
436
437
  },
437
438
  { content: 'Weather ok, tool failed' },
@@ -447,12 +448,12 @@ describe('chatWithTools — Enhanced Error Handling', () => {
447
448
  // Only called for the failing tool
448
449
  expect(onToolError).toHaveBeenCalledTimes(1);
449
450
  expect(onToolError).toHaveBeenCalledWith(
450
- expect.objectContaining({ name: 'bad_tool' }),
451
+ expect.objectContaining({ toolName: 'bad_tool' }),
451
452
  'fail',
452
453
  );
453
454
 
454
455
  // Both tool results fed back
455
- const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as AIMessage[];
456
+ const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[];
456
457
  const toolMessages = secondCallMessages.filter(m => m.role === 'tool');
457
458
  expect(toolMessages).toHaveLength(2);
458
459
  expect(result.content).toBe('Weather ok, tool failed');
@@ -478,7 +479,7 @@ describe('streamChatWithTools', () => {
478
479
  };
479
480
 
480
481
  const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
481
- const events: AIStreamEvent[] = [];
482
+ const events: TextStreamPart<ToolSet>[] = [];
482
483
  for await (const event of service.streamChatWithTools([{ role: 'user', content: 'Hi' }])) {
483
484
  events.push(event);
484
485
  }
@@ -486,16 +487,17 @@ describe('streamChatWithTools', () => {
486
487
  // Should emit the probed result as text-delta + finish (no double model call)
487
488
  expect(events).toHaveLength(2);
488
489
  expect(events[0].type).toBe('text-delta');
489
- expect(events[0].textDelta).toBe('Hello!');
490
+ expect((events[0] as any).text).toBe('Hello!');
490
491
  expect(events[1].type).toBe('finish');
491
492
  expect(adapter.chat).toHaveBeenCalledTimes(1);
492
493
  });
493
494
 
494
495
  it('should emit tool-call events during tool resolution', async () => {
495
- const toolCall: AIToolCall = {
496
- id: 'call_1',
497
- name: 'get_weather',
498
- arguments: JSON.stringify({ city: 'Tokyo' }),
496
+ const toolCall: ToolCallPart = {
497
+ type: 'tool-call',
498
+ toolCallId: 'call_1',
499
+ toolName: 'get_weather',
500
+ input: { city: 'Tokyo' },
499
501
  };
500
502
 
501
503
  let chatCallIndex = 0;
@@ -512,7 +514,7 @@ describe('streamChatWithTools', () => {
512
514
  };
513
515
 
514
516
  const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
515
- const events: AIStreamEvent[] = [];
517
+ const events: TextStreamPart<ToolSet>[] = [];
516
518
  for await (const event of service.streamChatWithTools(
517
519
  [{ role: 'user', content: 'Weather in Tokyo?' }],
518
520
  )) {
@@ -522,7 +524,7 @@ describe('streamChatWithTools', () => {
522
524
  // Should have tool-call event followed by text-delta + finish (no double call)
523
525
  const toolCallEvents = events.filter(e => e.type === 'tool-call');
524
526
  expect(toolCallEvents).toHaveLength(1);
525
- expect(toolCallEvents[0].toolCall?.name).toBe('get_weather');
527
+ expect((toolCallEvents[0] as any).toolName).toBe('get_weather');
526
528
 
527
529
  const finishEvent = events.find(e => e.type === 'finish');
528
530
  expect(finishEvent).toBeDefined();
@@ -539,7 +541,7 @@ describe('streamChatWithTools', () => {
539
541
 
540
542
  const emptyRegistry = new ToolRegistry();
541
543
  const service = new AIService({ adapter, logger: silentLogger, toolRegistry: emptyRegistry });
542
- const events: AIStreamEvent[] = [];
544
+ const events: TextStreamPart<ToolSet>[] = [];
543
545
  for await (const event of service.streamChatWithTools(
544
546
  [{ role: 'user', content: 'Hi' }],
545
547
  )) {
@@ -548,14 +550,14 @@ describe('streamChatWithTools', () => {
548
550
 
549
551
  expect(events).toHaveLength(2);
550
552
  expect(events[0].type).toBe('text-delta');
551
- expect(events[0].textDelta).toBe('Fallback response');
553
+ expect((events[0] as any).text).toBe('Fallback response');
552
554
  expect(events[1].type).toBe('finish');
553
555
  });
554
556
 
555
557
  it('should respect maxIterations in streaming tool loop', async () => {
556
558
  const infiniteToolCall: AIResult = {
557
559
  content: '',
558
- toolCalls: [{ id: 'c', name: 'get_weather', arguments: '{"city":"X"}' }],
560
+ toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'get_weather', input: { city: 'X' } }],
559
561
  };
560
562
 
561
563
  let callIndex = 0;
@@ -568,13 +570,13 @@ describe('streamChatWithTools', () => {
568
570
  }),
569
571
  complete: vi.fn(async () => ({ content: '' })),
570
572
  async *streamChat() {
571
- yield { type: 'text-delta' as const, textDelta: 'Forced stop' };
572
- yield { type: 'finish' as const, result: { content: 'Forced stop' } };
573
+ yield { type: 'text-delta' as const, id: '1', text: 'Forced stop' } as TextStreamPart<ToolSet>;
574
+ yield { type: 'finish' as const, finishReason: 'stop' as const, totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, rawFinishReason: 'stop' } as unknown as TextStreamPart<ToolSet>;
573
575
  },
574
576
  };
575
577
 
576
578
  const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
577
- const events: AIStreamEvent[] = [];
579
+ const events: TextStreamPart<ToolSet>[] = [];
578
580
  for await (const event of service.streamChatWithTools(
579
581
  [{ role: 'user', content: 'Loop' }],
580
582
  { maxIterations: 2 },
@@ -601,7 +603,7 @@ describe('streamChatWithTools', () => {
601
603
  if (chatCallIndex === 1) {
602
604
  return {
603
605
  content: '',
604
- toolCalls: [{ id: 'c1', name: 'critical_fail', arguments: '{}' }],
606
+ toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'critical_fail', input: {} }],
605
607
  };
606
608
  }
607
609
  return { content: 'Aborted' };
@@ -610,7 +612,7 @@ describe('streamChatWithTools', () => {
610
612
  };
611
613
 
612
614
  const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
613
- const events: AIStreamEvent[] = [];
615
+ const events: TextStreamPart<ToolSet>[] = [];
614
616
  for await (const event of service.streamChatWithTools(
615
617
  [{ role: 'user', content: 'Critical' }],
616
618
  { onToolError: () => 'abort' },