@objectstack/service-ai 4.0.1 → 4.0.3

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 (44) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +17 -0
  3. package/dist/index.cjs +1632 -355
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +330 -87
  6. package/dist/index.d.ts +330 -87
  7. package/dist/index.js +1623 -352
  8. package/dist/index.js.map +1 -1
  9. package/package.json +27 -5
  10. package/src/__tests__/ai-service.test.ts +260 -27
  11. package/src/__tests__/auth-and-toolcalling.test.ts +81 -29
  12. package/src/__tests__/chatbot-features.test.ts +397 -102
  13. package/src/__tests__/metadata-tools.test.ts +970 -0
  14. package/src/__tests__/objectql-conversation-service.test.ts +34 -16
  15. package/src/__tests__/tool-routes.test.ts +191 -0
  16. package/src/__tests__/vercel-stream-encoder.test.ts +310 -0
  17. package/src/adapters/index.ts +2 -0
  18. package/src/adapters/memory-adapter.ts +17 -9
  19. package/src/adapters/vercel-adapter.ts +148 -0
  20. package/src/agent-runtime.ts +27 -3
  21. package/src/agents/index.ts +1 -0
  22. package/src/agents/metadata-assistant-agent.ts +87 -0
  23. package/src/ai-service.ts +75 -36
  24. package/src/conversation/in-memory-conversation-service.ts +2 -2
  25. package/src/conversation/objectql-conversation-service.ts +67 -18
  26. package/src/index.ts +22 -2
  27. package/src/plugin.ts +237 -30
  28. package/src/routes/agent-routes.ts +68 -12
  29. package/src/routes/ai-routes.ts +93 -14
  30. package/src/routes/index.ts +1 -0
  31. package/src/routes/message-utils.ts +90 -0
  32. package/src/routes/tool-routes.ts +142 -0
  33. package/src/stream/index.ts +3 -0
  34. package/src/stream/vercel-stream-encoder.ts +153 -0
  35. package/src/tools/add-field.tool.ts +70 -0
  36. package/src/tools/create-object.tool.ts +66 -0
  37. package/src/tools/data-tools.ts +4 -101
  38. package/src/tools/delete-field.tool.ts +38 -0
  39. package/src/tools/describe-object.tool.ts +31 -0
  40. package/src/tools/index.ts +12 -1
  41. package/src/tools/list-objects.tool.ts +34 -0
  42. package/src/tools/metadata-tools.ts +430 -0
  43. package/src/tools/modify-field.tool.ts +44 -0
  44. package/src/tools/tool-registry.ts +32 -9
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { describe, it, expect, beforeEach, vi } from 'vitest';
4
4
  import type { IDataEngine } from '@objectstack/spec/contracts';
5
- import type { AIMessage } from '@objectstack/spec/contracts';
5
+ import type { ModelMessage } from '@objectstack/spec/contracts';
6
6
  import { ObjectQLConversationService } from '../conversation/objectql-conversation-service.js';
7
7
 
8
8
  // ─────────────────────────────────────────────────────────────────
@@ -242,7 +242,7 @@ describe('ObjectQLConversationService', () => {
242
242
  it('should add a user message to a conversation', async () => {
243
243
  const conv = await service.create({ title: 'Chat' });
244
244
 
245
- const msg: AIMessage = { role: 'user', content: 'Hello AI!' };
245
+ const msg: ModelMessage = { role: 'user', content: 'Hello AI!' };
246
246
  const updated = await service.addMessage(conv.id, msg);
247
247
 
248
248
  expect(updated.messages).toHaveLength(1);
@@ -253,33 +253,49 @@ describe('ObjectQLConversationService', () => {
253
253
 
254
254
  it('should add a tool message with toolCallId', async () => {
255
255
  const conv = await service.create();
256
- const msg: AIMessage = {
257
- role: 'tool',
258
- content: '{"temp": 22}',
259
- toolCallId: 'call_abc',
256
+ const msg: ModelMessage = {
257
+ role: 'tool' as const,
258
+ content: [{
259
+ type: 'tool-result' as const,
260
+ toolCallId: 'call_abc',
261
+ toolName: 'get_weather',
262
+ output: { type: 'text' as const, value: '{"temp": 22}' },
263
+ }],
260
264
  };
261
265
 
262
266
  const updated = await service.addMessage(conv.id, msg);
263
267
  expect(updated.messages).toHaveLength(1);
264
- expect(updated.messages[0].toolCallId).toBe('call_abc');
268
+ const firstMsg = updated.messages[0];
269
+ if (firstMsg.role === 'tool' && Array.isArray(firstMsg.content)) {
270
+ expect(firstMsg.content[0].toolCallId).toBe('call_abc');
271
+ } else {
272
+ throw new Error('Expected tool message with array content');
273
+ }
265
274
  });
266
275
 
267
276
  it('should add an assistant message with toolCalls', async () => {
268
277
  const conv = await service.create();
269
- const msg: AIMessage = {
270
- role: 'assistant',
271
- content: '',
272
- toolCalls: [{ id: 'call_1', name: 'get_weather', arguments: '{}' }],
278
+ const msg: ModelMessage = {
279
+ role: 'assistant' as const,
280
+ content: [
281
+ { type: 'tool-call' as const, toolCallId: 'call_1', toolName: 'get_weather', input: {} },
282
+ ],
273
283
  };
274
284
 
275
285
  const updated = await service.addMessage(conv.id, msg);
276
286
  expect(updated.messages).toHaveLength(1);
277
- expect(updated.messages[0].toolCalls).toHaveLength(1);
278
- expect(updated.messages[0].toolCalls![0].name).toBe('get_weather');
287
+ const firstMsg = updated.messages[0];
288
+ if (firstMsg.role === 'assistant' && Array.isArray(firstMsg.content)) {
289
+ const toolCallParts = firstMsg.content.filter((p) => p.type === 'tool-call');
290
+ expect(toolCallParts).toHaveLength(1);
291
+ expect(toolCallParts[0].toolName).toBe('get_weather');
292
+ } else {
293
+ throw new Error('Expected assistant message with array content');
294
+ }
279
295
  });
280
296
 
281
297
  it('should throw when adding message to non-existent conversation', async () => {
282
- const msg: AIMessage = { role: 'user', content: 'Hello' };
298
+ const msg: ModelMessage = { role: 'user', content: 'Hello' };
283
299
  await expect(service.addMessage('conv_ghost', msg)).rejects.toThrow(
284
300
  'Conversation "conv_ghost" not found',
285
301
  );
@@ -352,13 +368,15 @@ describe('ObjectQLConversationService', () => {
352
368
 
353
369
  it('should handle invalid JSON in tool_calls gracefully', async () => {
354
370
  const conv = await service.create();
355
- await service.addMessage(conv.id, { role: 'user', content: 'hi' });
371
+ await service.addMessage(conv.id, { role: 'assistant', content: 'checking tools' });
356
372
 
357
373
  // Manually corrupt tool_calls in the engine
358
374
  const msgs = await engine.find('ai_messages', { where: { conversation_id: conv.id } });
359
375
  msgs[0].tool_calls = 'broken{json';
360
376
 
361
377
  const fetched = await service.get(conv.id);
362
- expect(fetched!.messages[0].toolCalls).toBeUndefined();
378
+ // With broken tool_calls, the assistant message should still load with string content
379
+ expect(fetched!.messages[0].role).toBe('assistant');
380
+ expect(fetched!.messages[0].content).toBe('checking tools');
363
381
  });
364
382
  });
@@ -0,0 +1,191 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
4
+ import { buildToolRoutes } from '../routes/tool-routes.js';
5
+ import { AIService } from '../ai-service.js';
6
+ import { InMemoryConversationService } from '../conversation/in-memory-conversation-service.js';
7
+ import { ToolRegistry } from '../tools/tool-registry.js';
8
+ import type { Logger } from '@objectstack/spec/contracts';
9
+
10
+ const silentLogger: Logger = {
11
+ debug: vi.fn(),
12
+ info: vi.fn(),
13
+ warn: vi.fn(),
14
+ error: vi.fn(),
15
+ fatal: vi.fn(),
16
+ };
17
+
18
+ describe('Tool Routes', () => {
19
+ let aiService: AIService;
20
+ let routes: ReturnType<typeof buildToolRoutes>;
21
+
22
+ beforeEach(() => {
23
+ const conversationService = new InMemoryConversationService();
24
+ aiService = new AIService({
25
+ adapter: 'memory',
26
+ conversationService,
27
+ });
28
+
29
+ // Register a test tool
30
+ aiService.toolRegistry.register(
31
+ {
32
+ name: 'test_tool',
33
+ description: 'A test tool for playground',
34
+ parameters: {
35
+ type: 'object',
36
+ properties: {
37
+ message: { type: 'string' },
38
+ },
39
+ required: ['message'],
40
+ },
41
+ },
42
+ async (params: any) => {
43
+ return JSON.stringify({ echo: params.message });
44
+ }
45
+ );
46
+
47
+ routes = buildToolRoutes(aiService, silentLogger);
48
+ });
49
+
50
+ describe('GET /api/v1/ai/tools', () => {
51
+ it('should list all registered tools', async () => {
52
+ const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/tools');
53
+ expect(listRoute).toBeDefined();
54
+
55
+ const response = await listRoute!.handler({});
56
+ expect(response.status).toBe(200);
57
+ expect(response.body).toHaveProperty('tools');
58
+ expect(Array.isArray((response.body as any).tools)).toBe(true);
59
+
60
+ const tools = (response.body as any).tools;
61
+ expect(tools.length).toBeGreaterThan(0);
62
+ expect(tools.some((t: any) => t.name === 'test_tool')).toBe(true);
63
+ });
64
+
65
+ it('should require authentication', () => {
66
+ const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/tools');
67
+ expect(listRoute?.auth).toBe(true);
68
+ expect(listRoute?.permissions).toContain('ai:tools');
69
+ });
70
+ });
71
+
72
+ describe('POST /api/v1/ai/tools/:toolName/execute', () => {
73
+ it('should execute a tool with parameters', async () => {
74
+ const executeRoute = routes.find(
75
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
76
+ );
77
+ expect(executeRoute).toBeDefined();
78
+
79
+ const response = await executeRoute!.handler({
80
+ params: { toolName: 'test_tool' },
81
+ body: {
82
+ parameters: { message: 'Hello, Playground!' },
83
+ },
84
+ });
85
+
86
+ expect(response.status).toBe(200);
87
+ expect(response.body).toHaveProperty('result');
88
+ // Result is a JSON string from the handler
89
+ expect((response.body as any).result).toBe('{"echo":"Hello, Playground!"}');
90
+ expect((response.body as any).toolName).toBe('test_tool');
91
+ expect((response.body as any).duration).toBeTypeOf('number');
92
+ });
93
+
94
+ it('should return 404 for non-existent tool', async () => {
95
+ const executeRoute = routes.find(
96
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
97
+ );
98
+
99
+ const response = await executeRoute!.handler({
100
+ params: { toolName: 'non_existent_tool' },
101
+ body: {
102
+ parameters: {},
103
+ },
104
+ });
105
+
106
+ expect(response.status).toBe(404);
107
+ expect((response.body as any).error).toContain('not found');
108
+ });
109
+
110
+ it('should return 400 when toolName is missing', async () => {
111
+ const executeRoute = routes.find(
112
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
113
+ );
114
+
115
+ const response = await executeRoute!.handler({
116
+ body: {
117
+ parameters: {},
118
+ },
119
+ });
120
+
121
+ expect(response.status).toBe(400);
122
+ expect((response.body as any).error).toContain('toolName');
123
+ });
124
+
125
+ it('should return 400 when parameters are missing', async () => {
126
+ const executeRoute = routes.find(
127
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
128
+ );
129
+
130
+ const response = await executeRoute!.handler({
131
+ params: { toolName: 'test_tool' },
132
+ body: {},
133
+ });
134
+
135
+ expect(response.status).toBe(400);
136
+ expect((response.body as any).error).toContain('parameters');
137
+ });
138
+
139
+ it('should handle tool execution errors', async () => {
140
+ // Register a tool that throws an error
141
+ aiService.toolRegistry.register(
142
+ {
143
+ name: 'error_tool',
144
+ description: 'A tool that throws an error',
145
+ parameters: { type: 'object', properties: {} },
146
+ },
147
+ async () => {
148
+ throw new Error('Tool execution failed');
149
+ }
150
+ );
151
+
152
+ const executeRoute = routes.find(
153
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
154
+ );
155
+
156
+ const response = await executeRoute!.handler({
157
+ params: { toolName: 'error_tool' },
158
+ body: {
159
+ parameters: {},
160
+ },
161
+ });
162
+
163
+ expect(response.status).toBe(500);
164
+ expect((response.body as any).error).toContain('Tool execution failed');
165
+ expect((response.body as any).duration).toBeTypeOf('number');
166
+ });
167
+
168
+ it('should require authentication and permissions', () => {
169
+ const executeRoute = routes.find(
170
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
171
+ );
172
+
173
+ expect(executeRoute?.auth).toBe(true);
174
+ expect(executeRoute?.permissions).toContain('ai:tools');
175
+ expect(executeRoute?.permissions).toContain('ai:execute');
176
+ });
177
+ });
178
+
179
+ describe('Route Configuration', () => {
180
+ it('should register exactly 2 routes', () => {
181
+ expect(routes).toHaveLength(2);
182
+ });
183
+
184
+ it('should have descriptive route descriptions', () => {
185
+ routes.forEach(route => {
186
+ expect(route.description).toBeTruthy();
187
+ expect(route.description.length).toBeGreaterThan(10);
188
+ });
189
+ });
190
+ });
191
+ });
@@ -0,0 +1,310 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect } from 'vitest';
4
+ import type { TextStreamPart, ToolSet } from '@objectstack/spec/contracts';
5
+ import { encodeStreamPart, encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
6
+
7
+ // Helper to parse SSE frame payload
8
+ function parseSSE(frame: string): Record<string, unknown> | null {
9
+ if (!frame.startsWith('data: ') || !frame.endsWith('\n\n')) return null;
10
+ const json = frame.slice(6, -2);
11
+ if (json === '[DONE]') return null;
12
+ return JSON.parse(json);
13
+ }
14
+
15
+ // ─────────────────────────────────────────────────────────────────
16
+ // encodeStreamPart — individual frame encoding (v6 SSE format)
17
+ // ─────────────────────────────────────────────────────────────────
18
+
19
+ describe('encodeStreamPart', () => {
20
+ it('should encode text-delta as SSE frame', () => {
21
+ const part = { type: 'text-delta', text: 'Hello world' } as TextStreamPart<ToolSet>;
22
+ const frame = encodeStreamPart(part);
23
+ const payload = parseSSE(frame);
24
+ expect(payload).toEqual({ type: 'text-delta', id: '0', delta: 'Hello world' });
25
+ });
26
+
27
+ it('should JSON-escape text-delta content', () => {
28
+ const part = { type: 'text-delta', text: 'say "hi"\nnewline' } as TextStreamPart<ToolSet>;
29
+ const frame = encodeStreamPart(part);
30
+ expect(frame.startsWith('data: ')).toBe(true);
31
+ expect(frame.endsWith('\n\n')).toBe(true);
32
+
33
+ // Verify round-trip: decode the frame payload back to the original text
34
+ const payload = parseSSE(frame);
35
+ expect(payload).not.toBeNull();
36
+ expect((payload as Record<string, unknown>).delta).toBe('say "hi"\nnewline');
37
+ });
38
+
39
+ it('should encode tool-call as tool-input-available SSE frame', () => {
40
+ const part = {
41
+ type: 'tool-call',
42
+ toolCallId: 'call_1',
43
+ toolName: 'get_weather',
44
+ input: { location: 'San Francisco' },
45
+ } as TextStreamPart<ToolSet>;
46
+
47
+ const frame = encodeStreamPart(part);
48
+ const payload = parseSSE(frame);
49
+ expect(payload).toEqual({
50
+ type: 'tool-input-available',
51
+ toolCallId: 'call_1',
52
+ toolName: 'get_weather',
53
+ input: { location: 'San Francisco' },
54
+ });
55
+ });
56
+
57
+ it('should encode tool-input-start as SSE frame', () => {
58
+ const part = {
59
+ type: 'tool-input-start',
60
+ id: 'call_2',
61
+ toolName: 'search',
62
+ } as TextStreamPart<ToolSet>;
63
+
64
+ const frame = encodeStreamPart(part);
65
+ const payload = parseSSE(frame);
66
+ expect(payload).toEqual({
67
+ type: 'tool-input-start',
68
+ toolCallId: 'call_2',
69
+ toolName: 'search',
70
+ });
71
+ });
72
+
73
+ it('should encode tool-input-delta as SSE frame', () => {
74
+ const part = {
75
+ type: 'tool-input-delta',
76
+ id: 'call_2',
77
+ delta: '{"query":',
78
+ } as TextStreamPart<ToolSet>;
79
+
80
+ const frame = encodeStreamPart(part);
81
+ const payload = parseSSE(frame);
82
+ expect(payload).toEqual({
83
+ type: 'tool-input-delta',
84
+ toolCallId: 'call_2',
85
+ inputTextDelta: '{"query":',
86
+ });
87
+ });
88
+
89
+ it('should encode tool-result as tool-output-available SSE frame', () => {
90
+ const part = {
91
+ type: 'tool-result',
92
+ toolCallId: 'call_1',
93
+ toolName: 'get_weather',
94
+ output: { temperature: 72 },
95
+ } as TextStreamPart<ToolSet>;
96
+
97
+ const frame = encodeStreamPart(part);
98
+ const payload = parseSSE(frame);
99
+ expect(payload).toEqual({
100
+ type: 'tool-output-available',
101
+ toolCallId: 'call_1',
102
+ output: { temperature: 72 },
103
+ });
104
+ });
105
+
106
+ it('should return empty string for finish (handled by generator)', () => {
107
+ const part = {
108
+ type: 'finish',
109
+ finishReason: 'stop',
110
+ totalUsage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
111
+ rawFinishReason: 'stop',
112
+ } as unknown as TextStreamPart<ToolSet>;
113
+
114
+ expect(encodeStreamPart(part)).toBe('');
115
+ });
116
+
117
+ it('should return empty string for finish-step (handled by generator)', () => {
118
+ const part = {
119
+ type: 'finish-step',
120
+ finishReason: 'tool-calls',
121
+ usage: { promptTokens: 5, completionTokens: 10, totalTokens: 15 },
122
+ } as unknown as TextStreamPart<ToolSet>;
123
+
124
+ expect(encodeStreamPart(part)).toBe('');
125
+ });
126
+
127
+ it('should return empty string for unknown event types', () => {
128
+ const part = { type: 'unknown-internal' } as unknown as TextStreamPart<ToolSet>;
129
+ expect(encodeStreamPart(part)).toBe('');
130
+ });
131
+
132
+ it('should encode reasoning-start part with g: prefix', () => {
133
+ const part = {
134
+ type: 'reasoning-start',
135
+ id: 'r1',
136
+ } as unknown as TextStreamPart<ToolSet>;
137
+
138
+ const frame = encodeStreamPart(part);
139
+ expect(frame).toBe('g:{"text":""}\n');
140
+ });
141
+
142
+ it('should encode reasoning-delta part with g: prefix', () => {
143
+ const part = {
144
+ type: 'reasoning-delta',
145
+ id: 'r1',
146
+ text: 'Let me think through this step by step...',
147
+ } as unknown as TextStreamPart<ToolSet>;
148
+
149
+ const frame = encodeStreamPart(part);
150
+ expect(frame).toBe('g:{"text":"Let me think through this step by step..."}\n');
151
+ });
152
+
153
+ it('should encode reasoning-end part as empty (no specific end marker)', () => {
154
+ const part = {
155
+ type: 'reasoning-end',
156
+ id: 'r1',
157
+ } as unknown as TextStreamPart<ToolSet>;
158
+
159
+ const frame = encodeStreamPart(part);
160
+ expect(frame).toBe('');
161
+ });
162
+
163
+ it('should pass through custom step events', () => {
164
+ const part = {
165
+ type: 'step-start',
166
+ stepId: 'step_1',
167
+ stepName: 'Query database',
168
+ } as unknown as TextStreamPart<ToolSet>;
169
+
170
+ const frame = encodeStreamPart(part);
171
+ const payload = parseSSE(frame);
172
+ expect(payload).toEqual({
173
+ type: 'step-start',
174
+ stepId: 'step_1',
175
+ stepName: 'Query database',
176
+ });
177
+ });
178
+ });
179
+
180
+ // ─────────────────────────────────────────────────────────────────
181
+ // encodeVercelDataStream — async iterable transformation (v6 SSE)
182
+ //
183
+ // Lifecycle: start → start-step → text-start → ...events... → text-end → finish-step → finish → [DONE]
184
+ // ─────────────────────────────────────────────────────────────────
185
+
186
+ describe('encodeVercelDataStream', () => {
187
+ it('should transform stream events into v6 UI Message Stream frames', async () => {
188
+ async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
189
+ yield { type: 'text-delta', text: 'Hello' } as TextStreamPart<ToolSet>;
190
+ yield { type: 'text-delta', text: ' world' } as TextStreamPart<ToolSet>;
191
+ yield {
192
+ type: 'finish',
193
+ finishReason: 'stop',
194
+ totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
195
+ rawFinishReason: 'stop',
196
+ } as unknown as TextStreamPart<ToolSet>;
197
+ }
198
+
199
+ const frames: string[] = [];
200
+ for await (const frame of encodeVercelDataStream(source())) {
201
+ frames.push(frame);
202
+ }
203
+
204
+ // Preamble: start, start-step, text-start
205
+ // Content: 2 text-deltas
206
+ // Postamble: text-end, finish-step, finish, [DONE]
207
+ expect(frames).toHaveLength(9);
208
+
209
+ // Preamble
210
+ expect(parseSSE(frames[0])).toEqual({ type: 'start' });
211
+ expect(parseSSE(frames[1])).toEqual({ type: 'start-step' });
212
+ expect(parseSSE(frames[2])).toEqual({ type: 'text-start', id: '0' });
213
+
214
+ // Content
215
+ expect(parseSSE(frames[3])).toMatchObject({ type: 'text-delta', delta: 'Hello' });
216
+ expect(parseSSE(frames[4])).toMatchObject({ type: 'text-delta', delta: ' world' });
217
+
218
+ // Postamble
219
+ expect(parseSSE(frames[5])).toEqual({ type: 'text-end', id: '0' });
220
+ expect(parseSSE(frames[6])).toEqual({ type: 'finish-step' });
221
+ expect(parseSSE(frames[7])).toMatchObject({ type: 'finish', finishReason: 'stop' });
222
+ expect(frames[8]).toBe('data: [DONE]\n\n');
223
+ });
224
+
225
+ it('should skip events with no wire format mapping', async () => {
226
+ async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
227
+ yield { type: 'text-delta', text: 'Hi' } as TextStreamPart<ToolSet>;
228
+ yield { type: 'unknown-internal' } as unknown as TextStreamPart<ToolSet>;
229
+ yield {
230
+ type: 'finish',
231
+ finishReason: 'stop',
232
+ totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
233
+ rawFinishReason: 'stop',
234
+ } as unknown as TextStreamPart<ToolSet>;
235
+ }
236
+
237
+ const frames: string[] = [];
238
+ for await (const frame of encodeVercelDataStream(source())) {
239
+ frames.push(frame);
240
+ }
241
+
242
+ // Preamble(3) + 1 text-delta + Postamble(4) = 8 ('unknown-internal' dropped)
243
+ expect(frames).toHaveLength(8);
244
+ expect(parseSSE(frames[3])).toMatchObject({ type: 'text-delta', delta: 'Hi' });
245
+ });
246
+
247
+ it('should handle empty stream', async () => {
248
+ async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
249
+ // empty
250
+ }
251
+
252
+ const frames: string[] = [];
253
+ for await (const frame of encodeVercelDataStream(source())) {
254
+ frames.push(frame);
255
+ }
256
+
257
+ // Preamble(3) + text-end + finish-step + finish + [DONE] = 7
258
+ expect(frames).toHaveLength(7);
259
+ expect(parseSSE(frames[0])).toEqual({ type: 'start' });
260
+ expect(parseSSE(frames[3])).toEqual({ type: 'text-end', id: '0' });
261
+ expect(frames[6]).toBe('data: [DONE]\n\n');
262
+ });
263
+
264
+ it('should handle tool-call events in stream', async () => {
265
+ async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
266
+ yield {
267
+ type: 'tool-call',
268
+ toolCallId: 'call_1',
269
+ toolName: 'search',
270
+ input: { query: 'test' },
271
+ } as TextStreamPart<ToolSet>;
272
+ yield {
273
+ type: 'tool-result',
274
+ toolCallId: 'call_1',
275
+ toolName: 'search',
276
+ output: { hits: 42 },
277
+ } as TextStreamPart<ToolSet>;
278
+ yield {
279
+ type: 'finish',
280
+ finishReason: 'tool-calls',
281
+ totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
282
+ rawFinishReason: 'tool_calls',
283
+ } as unknown as TextStreamPart<ToolSet>;
284
+ }
285
+
286
+ const frames: string[] = [];
287
+ for await (const frame of encodeVercelDataStream(source())) {
288
+ frames.push(frame);
289
+ }
290
+
291
+ // Preamble(3) + tool-input-available + tool-output-available + Postamble(4) = 9
292
+ expect(frames).toHaveLength(9);
293
+
294
+ // Verify tool-call frame content
295
+ const toolCallPayload = parseSSE(frames[3]);
296
+ expect(toolCallPayload).toMatchObject({
297
+ type: 'tool-input-available',
298
+ toolCallId: 'call_1',
299
+ toolName: 'search',
300
+ input: { query: 'test' },
301
+ });
302
+
303
+ const toolResultPayload = parseSSE(frames[4]);
304
+ expect(toolResultPayload).toMatchObject({
305
+ type: 'tool-output-available',
306
+ toolCallId: 'call_1',
307
+ output: { hits: 42 },
308
+ });
309
+ });
310
+ });
@@ -2,3 +2,5 @@
2
2
 
3
3
  export type { LLMAdapter } from '@objectstack/spec/contracts';
4
4
  export { MemoryLLMAdapter } from './memory-adapter.js';
5
+ export { VercelLLMAdapter } from './vercel-adapter.js';
6
+ export type { VercelLLMAdapterConfig } from './vercel-adapter.js';
@@ -1,10 +1,11 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import type {
4
- AIMessage,
4
+ ModelMessage,
5
5
  AIRequestOptions,
6
6
  AIResult,
7
- AIStreamEvent,
7
+ TextStreamPart,
8
+ ToolSet,
8
9
  } from '@objectstack/spec/contracts';
9
10
  import type { LLMAdapter } from '@objectstack/spec/contracts';
10
11
 
@@ -17,10 +18,12 @@ import type { LLMAdapter } from '@objectstack/spec/contracts';
17
18
  export class MemoryLLMAdapter implements LLMAdapter {
18
19
  readonly name = 'memory';
19
20
 
20
- async chat(messages: AIMessage[], options?: AIRequestOptions): Promise<AIResult> {
21
+ async chat(messages: ModelMessage[], options?: AIRequestOptions): Promise<AIResult> {
21
22
  const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
23
+ const userContent = lastUserMessage?.content;
24
+ const text = typeof userContent === 'string' ? userContent : '(complex content)';
22
25
  const content = lastUserMessage
23
- ? `[memory] ${lastUserMessage.content}`
26
+ ? `[memory] ${text}`
24
27
  : '[memory] (no user message)';
25
28
 
26
29
  return {
@@ -39,17 +42,22 @@ export class MemoryLLMAdapter implements LLMAdapter {
39
42
  }
40
43
 
41
44
  async *streamChat(
42
- messages: AIMessage[],
45
+ messages: ModelMessage[],
43
46
  _options?: AIRequestOptions,
44
- ): AsyncIterable<AIStreamEvent> {
47
+ ): AsyncIterable<TextStreamPart<ToolSet>> {
45
48
  const result = await this.chat(messages);
46
49
  // Emit word-by-word deltas for realistic streaming simulation
47
50
  const words = result.content.split(' ');
48
51
  for (let i = 0; i < words.length; i++) {
49
- const textDelta = i === 0 ? words[i] : ` ${words[i]}`;
50
- yield { type: 'text-delta', textDelta };
52
+ const wordText = i === 0 ? words[i] : ` ${words[i]}`;
53
+ yield { type: 'text-delta', id: `delta_${i}`, text: wordText } as TextStreamPart<ToolSet>;
51
54
  }
52
- yield { type: 'finish', result };
55
+ yield {
56
+ type: 'finish',
57
+ finishReason: 'stop' as const,
58
+ totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
59
+ rawFinishReason: 'stop',
60
+ } as unknown as TextStreamPart<ToolSet>;
53
61
  }
54
62
 
55
63
  async embed(input: string | string[]): Promise<number[][]> {