@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.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +17 -0
- package/dist/index.cjs +1632 -355
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +330 -87
- package/dist/index.d.ts +330 -87
- package/dist/index.js +1623 -352
- package/dist/index.js.map +1 -1
- package/package.json +27 -5
- package/src/__tests__/ai-service.test.ts +260 -27
- package/src/__tests__/auth-and-toolcalling.test.ts +81 -29
- package/src/__tests__/chatbot-features.test.ts +397 -102
- package/src/__tests__/metadata-tools.test.ts +970 -0
- package/src/__tests__/objectql-conversation-service.test.ts +34 -16
- package/src/__tests__/tool-routes.test.ts +191 -0
- package/src/__tests__/vercel-stream-encoder.test.ts +310 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/memory-adapter.ts +17 -9
- package/src/adapters/vercel-adapter.ts +148 -0
- package/src/agent-runtime.ts +27 -3
- package/src/agents/index.ts +1 -0
- package/src/agents/metadata-assistant-agent.ts +87 -0
- package/src/ai-service.ts +75 -36
- package/src/conversation/in-memory-conversation-service.ts +2 -2
- package/src/conversation/objectql-conversation-service.ts +67 -18
- package/src/index.ts +22 -2
- package/src/plugin.ts +237 -30
- package/src/routes/agent-routes.ts +68 -12
- package/src/routes/ai-routes.ts +93 -14
- package/src/routes/index.ts +1 -0
- package/src/routes/message-utils.ts +90 -0
- package/src/routes/tool-routes.ts +142 -0
- package/src/stream/index.ts +3 -0
- package/src/stream/vercel-stream-encoder.ts +153 -0
- package/src/tools/add-field.tool.ts +70 -0
- package/src/tools/create-object.tool.ts +66 -0
- package/src/tools/data-tools.ts +4 -101
- package/src/tools/delete-field.tool.ts +38 -0
- package/src/tools/describe-object.tool.ts +31 -0
- package/src/tools/index.ts +12 -1
- package/src/tools/list-objects.tool.ts +34 -0
- package/src/tools/metadata-tools.ts +430 -0
- package/src/tools/modify-field.tool.ts +44 -0
- 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 {
|
|
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:
|
|
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:
|
|
257
|
-
role: 'tool',
|
|
258
|
-
content:
|
|
259
|
-
|
|
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
|
-
|
|
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:
|
|
270
|
-
role: 'assistant',
|
|
271
|
-
content:
|
|
272
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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:
|
|
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: '
|
|
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
|
-
|
|
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
|
+
});
|
package/src/adapters/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
|
-
|
|
4
|
+
ModelMessage,
|
|
5
5
|
AIRequestOptions,
|
|
6
6
|
AIResult,
|
|
7
|
-
|
|
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:
|
|
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] ${
|
|
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:
|
|
45
|
+
messages: ModelMessage[],
|
|
43
46
|
_options?: AIRequestOptions,
|
|
44
|
-
): AsyncIterable<
|
|
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
|
|
50
|
-
yield { type: 'text-delta',
|
|
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 {
|
|
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[][]> {
|