@objectstack/service-ai 4.0.0
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 +22 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +202 -0
- package/dist/index.cjs +1418 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3406 -0
- package/dist/index.d.ts +3406 -0
- package/dist/index.js +1378 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/ai-service.test.ts +731 -0
- package/src/__tests__/chatbot-features.test.ts +821 -0
- package/src/__tests__/objectql-conversation-service.test.ts +364 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/memory-adapter.ts +64 -0
- package/src/adapters/types.ts +3 -0
- package/src/agent-runtime.ts +130 -0
- package/src/agents/data-chat-agent.ts +79 -0
- package/src/agents/index.ts +3 -0
- package/src/ai-service.ts +205 -0
- package/src/conversation/in-memory-conversation-service.ts +103 -0
- package/src/conversation/index.ts +4 -0
- package/src/conversation/objectql-conversation-service.ts +252 -0
- package/src/index.ts +40 -0
- package/src/objects/ai-conversation.object.ts +86 -0
- package/src/objects/ai-message.object.ts +86 -0
- package/src/objects/index.ts +10 -0
- package/src/plugin.ts +184 -0
- package/src/routes/agent-routes.ts +132 -0
- package/src/routes/ai-routes.ts +286 -0
- package/src/routes/index.ts +4 -0
- package/src/tools/data-tools.ts +390 -0
- package/src/tools/index.ts +7 -0
- package/src/tools/tool-registry.ts +109 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,731 @@
|
|
|
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 { AIMessage, IAIService, AIStreamEvent } from '@objectstack/spec/contracts';
|
|
5
|
+
import { AIService } from '../ai-service.js';
|
|
6
|
+
import { MemoryLLMAdapter } from '../adapters/memory-adapter.js';
|
|
7
|
+
import { ToolRegistry } from '../tools/tool-registry.js';
|
|
8
|
+
import { InMemoryConversationService } from '../conversation/in-memory-conversation-service.js';
|
|
9
|
+
import { buildAIRoutes } from '../routes/ai-routes.js';
|
|
10
|
+
import { AIServicePlugin } from '../plugin.js';
|
|
11
|
+
import type { LLMAdapter } from '@objectstack/spec/contracts';
|
|
12
|
+
|
|
13
|
+
// Suppress logger output in tests
|
|
14
|
+
const silentLogger = {
|
|
15
|
+
info: vi.fn(),
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
warn: vi.fn(),
|
|
18
|
+
error: vi.fn(),
|
|
19
|
+
child: vi.fn().mockReturnThis(),
|
|
20
|
+
} as any;
|
|
21
|
+
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────
|
|
23
|
+
// MemoryLLMAdapter
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe('MemoryLLMAdapter', () => {
|
|
27
|
+
let adapter: MemoryLLMAdapter;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
adapter = new MemoryLLMAdapter();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should have name "memory"', () => {
|
|
34
|
+
expect(adapter.name).toBe('memory');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should echo the last user message in chat()', async () => {
|
|
38
|
+
const messages: AIMessage[] = [
|
|
39
|
+
{ role: 'system', content: 'You are helpful.' },
|
|
40
|
+
{ role: 'user', content: 'Hello AI' },
|
|
41
|
+
];
|
|
42
|
+
const result = await adapter.chat(messages);
|
|
43
|
+
expect(result.content).toBe('[memory] Hello AI');
|
|
44
|
+
expect(result.model).toBe('memory');
|
|
45
|
+
expect(result.usage).toBeDefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should handle no user message in chat()', async () => {
|
|
49
|
+
const messages: AIMessage[] = [{ role: 'system', content: 'System only' }];
|
|
50
|
+
const result = await adapter.chat(messages);
|
|
51
|
+
expect(result.content).toBe('[memory] (no user message)');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should echo prompt in complete()', async () => {
|
|
55
|
+
const result = await adapter.complete('test prompt');
|
|
56
|
+
expect(result.content).toBe('[memory] test prompt');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should stream word-by-word in streamChat()', async () => {
|
|
60
|
+
const messages: AIMessage[] = [{ role: 'user', content: 'Hi there' }];
|
|
61
|
+
const events: AIStreamEvent[] = [];
|
|
62
|
+
for await (const event of adapter.streamChat(messages)) {
|
|
63
|
+
events.push(event);
|
|
64
|
+
}
|
|
65
|
+
// "[memory]" + " Hi" + " there" = 3 text-delta events + 1 finish
|
|
66
|
+
expect(events.filter(e => e.type === 'text-delta').length).toBeGreaterThan(0);
|
|
67
|
+
expect(events[events.length - 1].type).toBe('finish');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should return zero vectors for embed()', async () => {
|
|
71
|
+
const result = await adapter.embed(['hello', 'world']);
|
|
72
|
+
expect(result).toHaveLength(2);
|
|
73
|
+
expect(result[0]).toEqual([0, 0, 0]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should list memory model', async () => {
|
|
77
|
+
const models = await adapter.listModels();
|
|
78
|
+
expect(models).toEqual(['memory']);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ─────────────────────────────────────────────────────────────────
|
|
83
|
+
// ToolRegistry
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe('ToolRegistry', () => {
|
|
87
|
+
let registry: ToolRegistry;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
registry = new ToolRegistry();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should register and retrieve a tool', () => {
|
|
94
|
+
const def = { name: 'test_tool', description: 'A test', parameters: {} };
|
|
95
|
+
registry.register(def, async () => 'result');
|
|
96
|
+
expect(registry.has('test_tool')).toBe(true);
|
|
97
|
+
expect(registry.getDefinition('test_tool')).toEqual(def);
|
|
98
|
+
expect(registry.size).toBe(1);
|
|
99
|
+
expect(registry.names()).toEqual(['test_tool']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should unregister a tool', () => {
|
|
103
|
+
registry.register({ name: 'tool_a', description: 'A', parameters: {} }, async () => '');
|
|
104
|
+
registry.unregister('tool_a');
|
|
105
|
+
expect(registry.has('tool_a')).toBe(false);
|
|
106
|
+
expect(registry.size).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should execute a tool call', async () => {
|
|
110
|
+
registry.register(
|
|
111
|
+
{ name: 'add', description: 'Add numbers', parameters: {} },
|
|
112
|
+
async (args) => String((args.a as number) + (args.b as number)),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const result = await registry.execute({
|
|
116
|
+
id: 'call_1',
|
|
117
|
+
name: 'add',
|
|
118
|
+
arguments: JSON.stringify({ a: 3, b: 4 }),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(result.toolCallId).toBe('call_1');
|
|
122
|
+
expect(result.content).toBe('7');
|
|
123
|
+
expect(result.isError).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should return error for unknown tool', async () => {
|
|
127
|
+
const result = await registry.execute({
|
|
128
|
+
id: 'call_x',
|
|
129
|
+
name: 'unknown',
|
|
130
|
+
arguments: '{}',
|
|
131
|
+
});
|
|
132
|
+
expect(result.isError).toBe(true);
|
|
133
|
+
expect(result.content).toContain('not registered');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should return error on handler failure', async () => {
|
|
137
|
+
registry.register(
|
|
138
|
+
{ name: 'fail_tool', description: 'Fails', parameters: {} },
|
|
139
|
+
async () => { throw new Error('boom'); },
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const result = await registry.execute({
|
|
143
|
+
id: 'call_f',
|
|
144
|
+
name: 'fail_tool',
|
|
145
|
+
arguments: '{}',
|
|
146
|
+
});
|
|
147
|
+
expect(result.isError).toBe(true);
|
|
148
|
+
expect(result.content).toBe('boom');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should execute multiple tool calls in parallel', async () => {
|
|
152
|
+
registry.register(
|
|
153
|
+
{ name: 'echo', description: 'Echo', parameters: {} },
|
|
154
|
+
async (args) => args.msg as string,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const results = await registry.executeAll([
|
|
158
|
+
{ id: 'c1', name: 'echo', arguments: '{"msg":"a"}' },
|
|
159
|
+
{ id: 'c2', name: 'echo', arguments: '{"msg":"b"}' },
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
expect(results).toHaveLength(2);
|
|
163
|
+
expect(results[0].content).toBe('a');
|
|
164
|
+
expect(results[1].content).toBe('b');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should return all definitions', () => {
|
|
168
|
+
registry.register({ name: 't1', description: 'T1', parameters: {} }, async () => '');
|
|
169
|
+
registry.register({ name: 't2', description: 'T2', parameters: {} }, async () => '');
|
|
170
|
+
expect(registry.getAll()).toHaveLength(2);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should clear all tools', () => {
|
|
174
|
+
registry.register({ name: 'x', description: 'X', parameters: {} }, async () => '');
|
|
175
|
+
registry.clear();
|
|
176
|
+
expect(registry.size).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ─────────────────────────────────────────────────────────────────
|
|
181
|
+
// InMemoryConversationService
|
|
182
|
+
// ─────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe('InMemoryConversationService', () => {
|
|
185
|
+
let svc: InMemoryConversationService;
|
|
186
|
+
|
|
187
|
+
beforeEach(() => {
|
|
188
|
+
svc = new InMemoryConversationService();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should create a conversation', async () => {
|
|
192
|
+
const conv = await svc.create({ title: 'Test', userId: 'u1' });
|
|
193
|
+
expect(conv.id).toBeDefined();
|
|
194
|
+
expect(conv.title).toBe('Test');
|
|
195
|
+
expect(conv.userId).toBe('u1');
|
|
196
|
+
expect(conv.messages).toHaveLength(0);
|
|
197
|
+
expect(conv.createdAt).toBeDefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should get a conversation by ID', async () => {
|
|
201
|
+
const created = await svc.create({ title: 'Lookup' });
|
|
202
|
+
const found = await svc.get(created.id);
|
|
203
|
+
expect(found).not.toBeNull();
|
|
204
|
+
expect(found!.id).toBe(created.id);
|
|
205
|
+
|
|
206
|
+
const missing = await svc.get('nonexistent');
|
|
207
|
+
expect(missing).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should list conversations with filters', async () => {
|
|
211
|
+
await svc.create({ userId: 'a', agentId: 'ag1' });
|
|
212
|
+
await svc.create({ userId: 'b', agentId: 'ag1' });
|
|
213
|
+
await svc.create({ userId: 'a', agentId: 'ag2' });
|
|
214
|
+
|
|
215
|
+
expect((await svc.list()).length).toBe(3);
|
|
216
|
+
expect((await svc.list({ userId: 'a' })).length).toBe(2);
|
|
217
|
+
expect((await svc.list({ agentId: 'ag1' })).length).toBe(2);
|
|
218
|
+
expect((await svc.list({ limit: 1 })).length).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should add messages to a conversation', async () => {
|
|
222
|
+
const conv = await svc.create({});
|
|
223
|
+
await svc.addMessage(conv.id, { role: 'user', content: 'Hi' });
|
|
224
|
+
const updated = await svc.addMessage(conv.id, { role: 'assistant', content: 'Hello!' });
|
|
225
|
+
expect(updated.messages).toHaveLength(2);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should throw when adding message to non-existent conversation', async () => {
|
|
229
|
+
await expect(
|
|
230
|
+
svc.addMessage('nope', { role: 'user', content: 'Hi' }),
|
|
231
|
+
).rejects.toThrow('not found');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should delete a conversation', async () => {
|
|
235
|
+
const conv = await svc.create({});
|
|
236
|
+
await svc.delete(conv.id);
|
|
237
|
+
expect(await svc.get(conv.id)).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should track size', async () => {
|
|
241
|
+
expect(svc.size).toBe(0);
|
|
242
|
+
await svc.create({});
|
|
243
|
+
expect(svc.size).toBe(1);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should clear all conversations', async () => {
|
|
247
|
+
await svc.create({});
|
|
248
|
+
await svc.create({});
|
|
249
|
+
svc.clear();
|
|
250
|
+
expect(svc.size).toBe(0);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ─────────────────────────────────────────────────────────────────
|
|
255
|
+
// AIService (Orchestrator)
|
|
256
|
+
// ─────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
describe('AIService', () => {
|
|
259
|
+
it('should use MemoryLLMAdapter by default', async () => {
|
|
260
|
+
const service = new AIService({ logger: silentLogger });
|
|
261
|
+
expect(service.adapterName).toBe('memory');
|
|
262
|
+
|
|
263
|
+
const result = await service.chat([{ role: 'user', content: 'Hi' }]);
|
|
264
|
+
expect(result.content).toBe('[memory] Hi');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should delegate complete() to adapter', async () => {
|
|
268
|
+
const service = new AIService({ logger: silentLogger });
|
|
269
|
+
const result = await service.complete('test');
|
|
270
|
+
expect(result.content).toBe('[memory] test');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should stream via adapter.streamChat()', async () => {
|
|
274
|
+
const service = new AIService({ logger: silentLogger });
|
|
275
|
+
const events: AIStreamEvent[] = [];
|
|
276
|
+
for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
|
|
277
|
+
events.push(event);
|
|
278
|
+
}
|
|
279
|
+
expect(events.length).toBeGreaterThan(1);
|
|
280
|
+
expect(events[events.length - 1].type).toBe('finish');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should fall back to non-streaming when adapter has no streamChat', async () => {
|
|
284
|
+
const adapter: LLMAdapter = {
|
|
285
|
+
name: 'no-stream',
|
|
286
|
+
chat: async () => ({ content: 'response', model: 'test' }),
|
|
287
|
+
complete: async () => ({ content: '' }),
|
|
288
|
+
// no streamChat
|
|
289
|
+
};
|
|
290
|
+
const service = new AIService({ adapter, logger: silentLogger });
|
|
291
|
+
|
|
292
|
+
const events: AIStreamEvent[] = [];
|
|
293
|
+
for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
|
|
294
|
+
events.push(event);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
expect(events).toHaveLength(2);
|
|
298
|
+
expect(events[0].type).toBe('text-delta');
|
|
299
|
+
expect(events[0].textDelta).toBe('response');
|
|
300
|
+
expect(events[1].type).toBe('finish');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should delegate embed() to adapter', async () => {
|
|
304
|
+
const service = new AIService({ logger: silentLogger });
|
|
305
|
+
const embeddings = await service.embed('hello');
|
|
306
|
+
expect(embeddings).toHaveLength(1);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should throw when adapter does not support embed()', async () => {
|
|
310
|
+
const adapter: LLMAdapter = {
|
|
311
|
+
name: 'no-embed',
|
|
312
|
+
chat: async () => ({ content: '' }),
|
|
313
|
+
complete: async () => ({ content: '' }),
|
|
314
|
+
};
|
|
315
|
+
const service = new AIService({ adapter, logger: silentLogger });
|
|
316
|
+
await expect(service.embed('hello')).rejects.toThrow('does not support embeddings');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should delegate listModels() to adapter', async () => {
|
|
320
|
+
const service = new AIService({ logger: silentLogger });
|
|
321
|
+
const models = await service.listModels();
|
|
322
|
+
expect(models).toEqual(['memory']);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should return empty array when adapter has no listModels()', async () => {
|
|
326
|
+
const adapter: LLMAdapter = {
|
|
327
|
+
name: 'no-models',
|
|
328
|
+
chat: async () => ({ content: '' }),
|
|
329
|
+
complete: async () => ({ content: '' }),
|
|
330
|
+
};
|
|
331
|
+
const service = new AIService({ adapter, logger: silentLogger });
|
|
332
|
+
const models = await service.listModels();
|
|
333
|
+
expect(models).toEqual([]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should expose toolRegistry and conversationService', () => {
|
|
337
|
+
const service = new AIService({ logger: silentLogger });
|
|
338
|
+
expect(service.toolRegistry).toBeInstanceOf(ToolRegistry);
|
|
339
|
+
expect(service.conversationService).toBeInstanceOf(InMemoryConversationService);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should accept custom adapter', async () => {
|
|
343
|
+
const customAdapter: LLMAdapter = {
|
|
344
|
+
name: 'custom',
|
|
345
|
+
chat: async () => ({ content: 'custom response' }),
|
|
346
|
+
complete: async (p) => ({ content: `custom: ${p}` }),
|
|
347
|
+
};
|
|
348
|
+
const service = new AIService({ adapter: customAdapter, logger: silentLogger });
|
|
349
|
+
expect(service.adapterName).toBe('custom');
|
|
350
|
+
|
|
351
|
+
const result = await service.chat([{ role: 'user', content: 'test' }]);
|
|
352
|
+
expect(result.content).toBe('custom response');
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ─────────────────────────────────────────────────────────────────
|
|
357
|
+
// Routes
|
|
358
|
+
// ─────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
describe('AI Routes', () => {
|
|
361
|
+
let service: AIService;
|
|
362
|
+
|
|
363
|
+
beforeEach(() => {
|
|
364
|
+
service = new AIService({ logger: silentLogger });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should build all expected routes', () => {
|
|
368
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
369
|
+
expect(routes.length).toBe(8);
|
|
370
|
+
|
|
371
|
+
const paths = routes.map(r => `${r.method} ${r.path}`);
|
|
372
|
+
expect(paths).toContain('POST /api/v1/ai/chat');
|
|
373
|
+
expect(paths).toContain('POST /api/v1/ai/chat/stream');
|
|
374
|
+
expect(paths).toContain('POST /api/v1/ai/complete');
|
|
375
|
+
expect(paths).toContain('GET /api/v1/ai/models');
|
|
376
|
+
expect(paths).toContain('POST /api/v1/ai/conversations');
|
|
377
|
+
expect(paths).toContain('GET /api/v1/ai/conversations');
|
|
378
|
+
expect(paths).toContain('POST /api/v1/ai/conversations/:id/messages');
|
|
379
|
+
expect(paths).toContain('DELETE /api/v1/ai/conversations/:id');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('POST /api/v1/ai/chat should return chat result', async () => {
|
|
383
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
384
|
+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
|
|
385
|
+
|
|
386
|
+
const response = await chatRoute.handler({
|
|
387
|
+
body: { messages: [{ role: 'user', content: 'Hi' }] },
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(response.status).toBe(200);
|
|
391
|
+
expect((response.body as any).content).toBe('[memory] Hi');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('POST /api/v1/ai/chat should return 400 without messages', async () => {
|
|
395
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
396
|
+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
|
|
397
|
+
|
|
398
|
+
const response = await chatRoute.handler({ body: {} });
|
|
399
|
+
expect(response.status).toBe(400);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('POST /api/v1/ai/chat/stream should return streaming response', async () => {
|
|
403
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
404
|
+
const streamRoute = routes.find(r => r.path === '/api/v1/ai/chat/stream')!;
|
|
405
|
+
|
|
406
|
+
const response = await streamRoute.handler({
|
|
407
|
+
body: { messages: [{ role: 'user', content: 'Hello' }] },
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
expect(response.status).toBe(200);
|
|
411
|
+
expect(response.stream).toBe(true);
|
|
412
|
+
expect(response.events).toBeDefined();
|
|
413
|
+
|
|
414
|
+
// Consume the stream
|
|
415
|
+
const events: unknown[] = [];
|
|
416
|
+
for await (const event of response.events!) {
|
|
417
|
+
events.push(event);
|
|
418
|
+
}
|
|
419
|
+
expect(events.length).toBeGreaterThan(0);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('POST /api/v1/ai/complete should return completion result', async () => {
|
|
423
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
424
|
+
const completeRoute = routes.find(r => r.path === '/api/v1/ai/complete')!;
|
|
425
|
+
|
|
426
|
+
const response = await completeRoute.handler({
|
|
427
|
+
body: { prompt: 'test prompt' },
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
expect(response.status).toBe(200);
|
|
431
|
+
expect((response.body as any).content).toBe('[memory] test prompt');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('POST /api/v1/ai/complete should return 400 without prompt', async () => {
|
|
435
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
436
|
+
const completeRoute = routes.find(r => r.path === '/api/v1/ai/complete')!;
|
|
437
|
+
|
|
438
|
+
const response = await completeRoute.handler({ body: {} });
|
|
439
|
+
expect(response.status).toBe(400);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('GET /api/v1/ai/models should return model list', async () => {
|
|
443
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
444
|
+
const modelsRoute = routes.find(r => r.path === '/api/v1/ai/models')!;
|
|
445
|
+
|
|
446
|
+
const response = await modelsRoute.handler({});
|
|
447
|
+
expect(response.status).toBe(200);
|
|
448
|
+
expect((response.body as any).models).toContain('memory');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('POST /api/v1/ai/conversations should create conversation', async () => {
|
|
452
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
453
|
+
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
454
|
+
|
|
455
|
+
const response = await createRoute.handler({
|
|
456
|
+
body: { title: 'Test Conv', userId: 'u1' },
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
expect(response.status).toBe(201);
|
|
460
|
+
expect((response.body as any).title).toBe('Test Conv');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('GET /api/v1/ai/conversations should list conversations', async () => {
|
|
464
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
465
|
+
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
466
|
+
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
|
|
467
|
+
|
|
468
|
+
await createRoute.handler({ body: { title: 'C1' } });
|
|
469
|
+
await createRoute.handler({ body: { title: 'C2' } });
|
|
470
|
+
|
|
471
|
+
const response = await listRoute.handler({});
|
|
472
|
+
expect(response.status).toBe(200);
|
|
473
|
+
expect((response.body as any).conversations).toHaveLength(2);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('POST /api/v1/ai/conversations/:id/messages should add message', async () => {
|
|
477
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
478
|
+
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
479
|
+
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
|
|
480
|
+
|
|
481
|
+
const created = await createRoute.handler({ body: {} });
|
|
482
|
+
const convId = (created.body as any).id;
|
|
483
|
+
|
|
484
|
+
const response = await addMsgRoute.handler({
|
|
485
|
+
params: { id: convId },
|
|
486
|
+
body: { role: 'user', content: 'Hi there' },
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
expect(response.status).toBe(200);
|
|
490
|
+
expect((response.body as any).messages).toHaveLength(1);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('POST /api/v1/ai/conversations/:id/messages should return 404 for unknown conversation', async () => {
|
|
494
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
495
|
+
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
|
|
496
|
+
|
|
497
|
+
const response = await addMsgRoute.handler({
|
|
498
|
+
params: { id: 'unknown' },
|
|
499
|
+
body: { role: 'user', content: 'Hi' },
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(response.status).toBe(404);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('DELETE /api/v1/ai/conversations/:id should delete conversation', async () => {
|
|
506
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
507
|
+
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
508
|
+
const deleteRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id')!;
|
|
509
|
+
|
|
510
|
+
const created = await createRoute.handler({ body: {} });
|
|
511
|
+
const convId = (created.body as any).id;
|
|
512
|
+
|
|
513
|
+
const response = await deleteRoute.handler({ params: { id: convId } });
|
|
514
|
+
expect(response.status).toBe(204);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ── Message validation ───────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
it('POST /api/v1/ai/chat should return 400 for messages with invalid role', async () => {
|
|
520
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
521
|
+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
|
|
522
|
+
|
|
523
|
+
const response = await chatRoute.handler({
|
|
524
|
+
body: { messages: [{ role: 'invalid', content: 'Hi' }] },
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
expect(response.status).toBe(400);
|
|
528
|
+
expect((response.body as any).error).toContain('message.role');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('POST /api/v1/ai/chat should return 400 for messages with non-string content', async () => {
|
|
532
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
533
|
+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
|
|
534
|
+
|
|
535
|
+
const response = await chatRoute.handler({
|
|
536
|
+
body: { messages: [{ role: 'user', content: 123 }] },
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
expect(response.status).toBe(400);
|
|
540
|
+
expect((response.body as any).error).toContain('content');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('POST /api/v1/ai/conversations/:id/messages should return 400 for invalid role', async () => {
|
|
544
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
545
|
+
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
546
|
+
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
|
|
547
|
+
|
|
548
|
+
const created = await createRoute.handler({ body: {} });
|
|
549
|
+
const convId = (created.body as any).id;
|
|
550
|
+
|
|
551
|
+
const response = await addMsgRoute.handler({
|
|
552
|
+
params: { id: convId },
|
|
553
|
+
body: { role: 'invalid_role', content: 'Hi' },
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
expect(response.status).toBe(400);
|
|
557
|
+
expect((response.body as any).error).toContain('message.role');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('POST /api/v1/ai/conversations/:id/messages should return 400 for missing content', async () => {
|
|
561
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
562
|
+
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
|
|
563
|
+
|
|
564
|
+
const response = await addMsgRoute.handler({
|
|
565
|
+
params: { id: 'conv_1' },
|
|
566
|
+
body: { role: 'user' },
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
expect(response.status).toBe(400);
|
|
570
|
+
expect((response.body as any).error).toContain('content');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// ── Limit parsing ───────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
it('GET /api/v1/ai/conversations should parse limit from query string', async () => {
|
|
576
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
577
|
+
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
578
|
+
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
|
|
579
|
+
|
|
580
|
+
await createRoute.handler({ body: { title: 'C1' } });
|
|
581
|
+
await createRoute.handler({ body: { title: 'C2' } });
|
|
582
|
+
await createRoute.handler({ body: { title: 'C3' } });
|
|
583
|
+
|
|
584
|
+
const response = await listRoute.handler({ query: { limit: '2' } });
|
|
585
|
+
expect(response.status).toBe(200);
|
|
586
|
+
expect((response.body as any).conversations).toHaveLength(2);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('GET /api/v1/ai/conversations should return 400 for invalid limit', async () => {
|
|
590
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
591
|
+
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
|
|
592
|
+
|
|
593
|
+
const response = await listRoute.handler({ query: { limit: 'abc' } });
|
|
594
|
+
expect(response.status).toBe(400);
|
|
595
|
+
expect((response.body as any).error).toContain('limit');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('GET /api/v1/ai/conversations should return 400 for negative limit', async () => {
|
|
599
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
600
|
+
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
|
|
601
|
+
|
|
602
|
+
const response = await listRoute.handler({ query: { limit: '-1' } });
|
|
603
|
+
expect(response.status).toBe(400);
|
|
604
|
+
expect((response.body as any).error).toContain('limit');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// ── Tool message in chat ────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
it('POST /api/v1/ai/chat should accept tool role messages', async () => {
|
|
610
|
+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
611
|
+
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
|
|
612
|
+
|
|
613
|
+
const response = await chatRoute.handler({
|
|
614
|
+
body: {
|
|
615
|
+
messages: [
|
|
616
|
+
{ role: 'user', content: 'What is the weather?' },
|
|
617
|
+
{ role: 'assistant', content: '' },
|
|
618
|
+
{ role: 'tool', content: '{"temp": 22}', toolCallId: 'call_1' },
|
|
619
|
+
],
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
expect(response.status).toBe(200);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// ─────────────────────────────────────────────────────────────────
|
|
628
|
+
// AIServicePlugin (Integration)
|
|
629
|
+
// ─────────────────────────────────────────────────────────────────
|
|
630
|
+
|
|
631
|
+
describe('AIServicePlugin', () => {
|
|
632
|
+
function createMockContext() {
|
|
633
|
+
const services = new Map<string, unknown>();
|
|
634
|
+
const hooks = new Map<string, Function[]>();
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
registerService: vi.fn((name: string, service: unknown) => services.set(name, service)),
|
|
638
|
+
replaceService: vi.fn((name: string, service: unknown) => services.set(name, service)),
|
|
639
|
+
getService: vi.fn(<T>(name: string): T => {
|
|
640
|
+
if (!services.has(name)) throw new Error(`Service "${name}" not found`);
|
|
641
|
+
return services.get(name) as T;
|
|
642
|
+
}),
|
|
643
|
+
getServices: vi.fn(() => services),
|
|
644
|
+
hook: vi.fn((name: string, handler: Function) => {
|
|
645
|
+
if (!hooks.has(name)) hooks.set(name, []);
|
|
646
|
+
hooks.get(name)!.push(handler);
|
|
647
|
+
}),
|
|
648
|
+
trigger: vi.fn(async () => {}),
|
|
649
|
+
logger: silentLogger,
|
|
650
|
+
getKernel: vi.fn(),
|
|
651
|
+
} as any;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
it('should register as "ai" service on init', async () => {
|
|
655
|
+
const plugin = new AIServicePlugin();
|
|
656
|
+
const ctx = createMockContext();
|
|
657
|
+
|
|
658
|
+
await plugin.init(ctx);
|
|
659
|
+
|
|
660
|
+
expect(ctx.registerService).toHaveBeenCalledWith('ai', expect.any(Object));
|
|
661
|
+
const service = ctx.getService<IAIService>('ai');
|
|
662
|
+
expect(service).toBeDefined();
|
|
663
|
+
expect(typeof service.chat).toBe('function');
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('should have correct plugin metadata', () => {
|
|
667
|
+
const plugin = new AIServicePlugin();
|
|
668
|
+
expect(plugin.name).toBe('com.objectstack.service-ai');
|
|
669
|
+
expect(plugin.version).toBe('1.0.0');
|
|
670
|
+
expect(plugin.type).toBe('standard');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('should trigger ai:ready on start', async () => {
|
|
674
|
+
const plugin = new AIServicePlugin();
|
|
675
|
+
const ctx = createMockContext();
|
|
676
|
+
|
|
677
|
+
await plugin.init(ctx);
|
|
678
|
+
await plugin.start!(ctx);
|
|
679
|
+
|
|
680
|
+
expect(ctx.trigger).toHaveBeenCalledWith('ai:ready', expect.any(Object));
|
|
681
|
+
expect(ctx.trigger).toHaveBeenCalledWith('ai:routes', expect.any(Array));
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('should use custom adapter when provided', async () => {
|
|
685
|
+
const customAdapter: LLMAdapter = {
|
|
686
|
+
name: 'custom-test',
|
|
687
|
+
chat: async () => ({ content: 'custom' }),
|
|
688
|
+
complete: async () => ({ content: '' }),
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
const plugin = new AIServicePlugin({ adapter: customAdapter });
|
|
692
|
+
const ctx = createMockContext();
|
|
693
|
+
|
|
694
|
+
await plugin.init(ctx);
|
|
695
|
+
|
|
696
|
+
const service = ctx.getService<AIService>('ai');
|
|
697
|
+
expect(service.adapterName).toBe('custom-test');
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('should replace existing AI service', async () => {
|
|
701
|
+
const plugin = new AIServicePlugin();
|
|
702
|
+
const ctx = createMockContext();
|
|
703
|
+
|
|
704
|
+
// Pre-register a mock AI service
|
|
705
|
+
ctx.registerService('ai', { chat: vi.fn(), complete: vi.fn() });
|
|
706
|
+
|
|
707
|
+
await plugin.init(ctx);
|
|
708
|
+
|
|
709
|
+
expect(ctx.replaceService).toHaveBeenCalledWith('ai', expect.any(Object));
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('should clean up on destroy', async () => {
|
|
713
|
+
const plugin = new AIServicePlugin();
|
|
714
|
+
const ctx = createMockContext();
|
|
715
|
+
|
|
716
|
+
await plugin.init(ctx);
|
|
717
|
+
await plugin.destroy!();
|
|
718
|
+
|
|
719
|
+
// After destroy, the plugin should not throw
|
|
720
|
+
// (internal service reference cleared)
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('should register debug hook when debug=true', async () => {
|
|
724
|
+
const plugin = new AIServicePlugin({ debug: true });
|
|
725
|
+
const ctx = createMockContext();
|
|
726
|
+
|
|
727
|
+
await plugin.init(ctx);
|
|
728
|
+
|
|
729
|
+
expect(ctx.hook).toHaveBeenCalledWith('ai:beforeChat', expect.any(Function));
|
|
730
|
+
});
|
|
731
|
+
});
|