@objectstack/service-ai 4.0.3 → 4.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -0
- package/dist/index.cjs +1176 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1225 -430
- package/dist/index.d.ts +1225 -430
- package/dist/index.js +1160 -128
- package/dist/index.js.map +1 -1
- package/package.json +35 -8
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -53
- package/src/__tests__/ai-service.test.ts +0 -964
- package/src/__tests__/auth-and-toolcalling.test.ts +0 -677
- package/src/__tests__/chatbot-features.test.ts +0 -1116
- package/src/__tests__/metadata-tools.test.ts +0 -970
- package/src/__tests__/objectql-conversation-service.test.ts +0 -382
- package/src/__tests__/tool-routes.test.ts +0 -191
- package/src/__tests__/vercel-stream-encoder.test.ts +0 -310
- package/src/adapters/index.ts +0 -6
- package/src/adapters/memory-adapter.ts +0 -72
- package/src/adapters/types.ts +0 -3
- package/src/adapters/vercel-adapter.ts +0 -148
- package/src/agent-runtime.ts +0 -154
- package/src/agents/data-chat-agent.ts +0 -79
- package/src/agents/index.ts +0 -4
- package/src/agents/metadata-assistant-agent.ts +0 -87
- package/src/ai-service.ts +0 -364
- package/src/conversation/in-memory-conversation-service.ts +0 -103
- package/src/conversation/index.ts +0 -4
- package/src/conversation/objectql-conversation-service.ts +0 -301
- package/src/index.ts +0 -60
- package/src/objects/ai-conversation.object.ts +0 -86
- package/src/objects/ai-message.object.ts +0 -86
- package/src/objects/index.ts +0 -10
- package/src/plugin.ts +0 -391
- package/src/routes/agent-routes.ts +0 -190
- package/src/routes/ai-routes.ts +0 -439
- package/src/routes/index.ts +0 -5
- package/src/routes/message-utils.ts +0 -90
- package/src/routes/tool-routes.ts +0 -142
- package/src/stream/index.ts +0 -3
- package/src/stream/vercel-stream-encoder.ts +0 -153
- package/src/tools/add-field.tool.ts +0 -70
- package/src/tools/create-object.tool.ts +0 -66
- package/src/tools/data-tools.ts +0 -293
- package/src/tools/delete-field.tool.ts +0 -38
- package/src/tools/describe-object.tool.ts +0 -31
- package/src/tools/index.ts +0 -18
- package/src/tools/list-objects.tool.ts +0 -34
- package/src/tools/metadata-tools.ts +0 -430
- package/src/tools/modify-field.tool.ts +0 -44
- package/src/tools/tool-registry.ts +0 -132
- package/tsconfig.json +0 -17
|
@@ -1,964 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
-
import type { ModelMessage, IAIService, TextStreamPart, ToolSet } 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: ModelMessage[] = [
|
|
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: ModelMessage[] = [{ 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: ModelMessage[] = [{ role: 'user', content: 'Hi there' }];
|
|
61
|
-
const events: TextStreamPart<ToolSet>[] = [];
|
|
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
|
-
type: 'tool-call',
|
|
117
|
-
toolCallId: 'call_1',
|
|
118
|
-
toolName: 'add',
|
|
119
|
-
input: { a: 3, b: 4 },
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
expect(result.toolCallId).toBe('call_1');
|
|
123
|
-
expect(result.output).toEqual({ type: 'text', value: '7' });
|
|
124
|
-
expect(result.isError).toBeUndefined();
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('should return error for unknown tool', async () => {
|
|
128
|
-
const result = await registry.execute({
|
|
129
|
-
type: 'tool-call',
|
|
130
|
-
toolCallId: 'call_x',
|
|
131
|
-
toolName: 'unknown',
|
|
132
|
-
input: {},
|
|
133
|
-
});
|
|
134
|
-
expect(result.isError).toBe(true);
|
|
135
|
-
expect(result.output).toEqual(expect.objectContaining({ type: 'text', value: expect.stringContaining('not registered') }));
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('should return error on handler failure', async () => {
|
|
139
|
-
registry.register(
|
|
140
|
-
{ name: 'fail_tool', description: 'Fails', parameters: {} },
|
|
141
|
-
async () => { throw new Error('boom'); },
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
const result = await registry.execute({
|
|
145
|
-
type: 'tool-call',
|
|
146
|
-
toolCallId: 'call_f',
|
|
147
|
-
toolName: 'fail_tool',
|
|
148
|
-
input: {},
|
|
149
|
-
});
|
|
150
|
-
expect(result.isError).toBe(true);
|
|
151
|
-
expect(result.output).toEqual({ type: 'text', value: 'boom' });
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('should execute multiple tool calls in parallel', async () => {
|
|
155
|
-
registry.register(
|
|
156
|
-
{ name: 'echo', description: 'Echo', parameters: {} },
|
|
157
|
-
async (args) => args.msg as string,
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
const results = await registry.executeAll([
|
|
161
|
-
{ type: 'tool-call', toolCallId: 'c1', toolName: 'echo', input: { msg: 'a' } },
|
|
162
|
-
{ type: 'tool-call', toolCallId: 'c2', toolName: 'echo', input: { msg: 'b' } },
|
|
163
|
-
]);
|
|
164
|
-
|
|
165
|
-
expect(results).toHaveLength(2);
|
|
166
|
-
expect(results[0].output).toEqual({ type: 'text', value: 'a' });
|
|
167
|
-
expect(results[1].output).toEqual({ type: 'text', value: 'b' });
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('should return all definitions', () => {
|
|
171
|
-
registry.register({ name: 't1', description: 'T1', parameters: {} }, async () => '');
|
|
172
|
-
registry.register({ name: 't2', description: 'T2', parameters: {} }, async () => '');
|
|
173
|
-
expect(registry.getAll()).toHaveLength(2);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('should clear all tools', () => {
|
|
177
|
-
registry.register({ name: 'x', description: 'X', parameters: {} }, async () => '');
|
|
178
|
-
registry.clear();
|
|
179
|
-
expect(registry.size).toBe(0);
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// ─────────────────────────────────────────────────────────────────
|
|
184
|
-
// InMemoryConversationService
|
|
185
|
-
// ─────────────────────────────────────────────────────────────────
|
|
186
|
-
|
|
187
|
-
describe('InMemoryConversationService', () => {
|
|
188
|
-
let svc: InMemoryConversationService;
|
|
189
|
-
|
|
190
|
-
beforeEach(() => {
|
|
191
|
-
svc = new InMemoryConversationService();
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('should create a conversation', async () => {
|
|
195
|
-
const conv = await svc.create({ title: 'Test', userId: 'u1' });
|
|
196
|
-
expect(conv.id).toBeDefined();
|
|
197
|
-
expect(conv.title).toBe('Test');
|
|
198
|
-
expect(conv.userId).toBe('u1');
|
|
199
|
-
expect(conv.messages).toHaveLength(0);
|
|
200
|
-
expect(conv.createdAt).toBeDefined();
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it('should get a conversation by ID', async () => {
|
|
204
|
-
const created = await svc.create({ title: 'Lookup' });
|
|
205
|
-
const found = await svc.get(created.id);
|
|
206
|
-
expect(found).not.toBeNull();
|
|
207
|
-
expect(found!.id).toBe(created.id);
|
|
208
|
-
|
|
209
|
-
const missing = await svc.get('nonexistent');
|
|
210
|
-
expect(missing).toBeNull();
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('should list conversations with filters', async () => {
|
|
214
|
-
await svc.create({ userId: 'a', agentId: 'ag1' });
|
|
215
|
-
await svc.create({ userId: 'b', agentId: 'ag1' });
|
|
216
|
-
await svc.create({ userId: 'a', agentId: 'ag2' });
|
|
217
|
-
|
|
218
|
-
expect((await svc.list()).length).toBe(3);
|
|
219
|
-
expect((await svc.list({ userId: 'a' })).length).toBe(2);
|
|
220
|
-
expect((await svc.list({ agentId: 'ag1' })).length).toBe(2);
|
|
221
|
-
expect((await svc.list({ limit: 1 })).length).toBe(1);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('should add messages to a conversation', async () => {
|
|
225
|
-
const conv = await svc.create({});
|
|
226
|
-
await svc.addMessage(conv.id, { role: 'user', content: 'Hi' });
|
|
227
|
-
const updated = await svc.addMessage(conv.id, { role: 'assistant', content: 'Hello!' });
|
|
228
|
-
expect(updated.messages).toHaveLength(2);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it('should throw when adding message to non-existent conversation', async () => {
|
|
232
|
-
await expect(
|
|
233
|
-
svc.addMessage('nope', { role: 'user', content: 'Hi' }),
|
|
234
|
-
).rejects.toThrow('not found');
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('should delete a conversation', async () => {
|
|
238
|
-
const conv = await svc.create({});
|
|
239
|
-
await svc.delete(conv.id);
|
|
240
|
-
expect(await svc.get(conv.id)).toBeNull();
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('should track size', async () => {
|
|
244
|
-
expect(svc.size).toBe(0);
|
|
245
|
-
await svc.create({});
|
|
246
|
-
expect(svc.size).toBe(1);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('should clear all conversations', async () => {
|
|
250
|
-
await svc.create({});
|
|
251
|
-
await svc.create({});
|
|
252
|
-
svc.clear();
|
|
253
|
-
expect(svc.size).toBe(0);
|
|
254
|
-
});
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// ─────────────────────────────────────────────────────────────────
|
|
258
|
-
// AIService (Orchestrator)
|
|
259
|
-
// ─────────────────────────────────────────────────────────────────
|
|
260
|
-
|
|
261
|
-
describe('AIService', () => {
|
|
262
|
-
it('should use MemoryLLMAdapter by default', async () => {
|
|
263
|
-
const service = new AIService({ logger: silentLogger });
|
|
264
|
-
expect(service.adapterName).toBe('memory');
|
|
265
|
-
|
|
266
|
-
const result = await service.chat([{ role: 'user', content: 'Hi' }]);
|
|
267
|
-
expect(result.content).toBe('[memory] Hi');
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it('should delegate complete() to adapter', async () => {
|
|
271
|
-
const service = new AIService({ logger: silentLogger });
|
|
272
|
-
const result = await service.complete('test');
|
|
273
|
-
expect(result.content).toBe('[memory] test');
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it('should stream via adapter.streamChat()', async () => {
|
|
277
|
-
const service = new AIService({ logger: silentLogger });
|
|
278
|
-
const events: TextStreamPart<ToolSet>[] = [];
|
|
279
|
-
for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
|
|
280
|
-
events.push(event);
|
|
281
|
-
}
|
|
282
|
-
expect(events.length).toBeGreaterThan(1);
|
|
283
|
-
expect(events[events.length - 1].type).toBe('finish');
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('should fall back to non-streaming when adapter has no streamChat', async () => {
|
|
287
|
-
const adapter: LLMAdapter = {
|
|
288
|
-
name: 'no-stream',
|
|
289
|
-
chat: async () => ({ content: 'response', model: 'test' }),
|
|
290
|
-
complete: async () => ({ content: '' }),
|
|
291
|
-
// no streamChat
|
|
292
|
-
};
|
|
293
|
-
const service = new AIService({ adapter, logger: silentLogger });
|
|
294
|
-
|
|
295
|
-
const events: TextStreamPart<ToolSet>[] = [];
|
|
296
|
-
for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
|
|
297
|
-
events.push(event);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
expect(events).toHaveLength(2);
|
|
301
|
-
expect(events[0].type).toBe('text-delta');
|
|
302
|
-
expect(events[0].type === 'text-delta' && events[0].text).toBe('response');
|
|
303
|
-
expect(events[1].type).toBe('finish');
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it('should delegate embed() to adapter', async () => {
|
|
307
|
-
const service = new AIService({ logger: silentLogger });
|
|
308
|
-
const embeddings = await service.embed('hello');
|
|
309
|
-
expect(embeddings).toHaveLength(1);
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it('should throw when adapter does not support embed()', async () => {
|
|
313
|
-
const adapter: LLMAdapter = {
|
|
314
|
-
name: 'no-embed',
|
|
315
|
-
chat: async () => ({ content: '' }),
|
|
316
|
-
complete: async () => ({ content: '' }),
|
|
317
|
-
};
|
|
318
|
-
const service = new AIService({ adapter, logger: silentLogger });
|
|
319
|
-
await expect(service.embed('hello')).rejects.toThrow('does not support embeddings');
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
it('should delegate listModels() to adapter', async () => {
|
|
323
|
-
const service = new AIService({ logger: silentLogger });
|
|
324
|
-
const models = await service.listModels();
|
|
325
|
-
expect(models).toEqual(['memory']);
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it('should return empty array when adapter has no listModels()', async () => {
|
|
329
|
-
const adapter: LLMAdapter = {
|
|
330
|
-
name: 'no-models',
|
|
331
|
-
chat: async () => ({ content: '' }),
|
|
332
|
-
complete: async () => ({ content: '' }),
|
|
333
|
-
};
|
|
334
|
-
const service = new AIService({ adapter, logger: silentLogger });
|
|
335
|
-
const models = await service.listModels();
|
|
336
|
-
expect(models).toEqual([]);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it('should expose toolRegistry and conversationService', () => {
|
|
340
|
-
const service = new AIService({ logger: silentLogger });
|
|
341
|
-
expect(service.toolRegistry).toBeInstanceOf(ToolRegistry);
|
|
342
|
-
expect(service.conversationService).toBeInstanceOf(InMemoryConversationService);
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
it('should accept custom adapter', async () => {
|
|
346
|
-
const customAdapter: LLMAdapter = {
|
|
347
|
-
name: 'custom',
|
|
348
|
-
chat: async () => ({ content: 'custom response' }),
|
|
349
|
-
complete: async (p) => ({ content: `custom: ${p}` }),
|
|
350
|
-
};
|
|
351
|
-
const service = new AIService({ adapter: customAdapter, logger: silentLogger });
|
|
352
|
-
expect(service.adapterName).toBe('custom');
|
|
353
|
-
|
|
354
|
-
const result = await service.chat([{ role: 'user', content: 'test' }]);
|
|
355
|
-
expect(result.content).toBe('custom response');
|
|
356
|
-
});
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
// ─────────────────────────────────────────────────────────────────
|
|
360
|
-
// Routes
|
|
361
|
-
// ─────────────────────────────────────────────────────────────────
|
|
362
|
-
|
|
363
|
-
describe('AI Routes', () => {
|
|
364
|
-
let service: AIService;
|
|
365
|
-
|
|
366
|
-
beforeEach(() => {
|
|
367
|
-
service = new AIService({ logger: silentLogger });
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
it('should build all expected routes', () => {
|
|
371
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
372
|
-
expect(routes.length).toBe(8);
|
|
373
|
-
|
|
374
|
-
const paths = routes.map(r => `${r.method} ${r.path}`);
|
|
375
|
-
expect(paths).toContain('POST /api/v1/ai/chat');
|
|
376
|
-
expect(paths).toContain('POST /api/v1/ai/chat/stream');
|
|
377
|
-
expect(paths).toContain('POST /api/v1/ai/complete');
|
|
378
|
-
expect(paths).toContain('GET /api/v1/ai/models');
|
|
379
|
-
expect(paths).toContain('POST /api/v1/ai/conversations');
|
|
380
|
-
expect(paths).toContain('GET /api/v1/ai/conversations');
|
|
381
|
-
expect(paths).toContain('POST /api/v1/ai/conversations/:id/messages');
|
|
382
|
-
expect(paths).toContain('DELETE /api/v1/ai/conversations/:id');
|
|
383
|
-
});
|
|
384
|
-
|
|
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 () => {
|
|
398
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
399
|
-
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
|
|
400
|
-
|
|
401
|
-
const response = await chatRoute.handler({
|
|
402
|
-
body: { messages: [{ role: 'user', content: 'Hi' }] },
|
|
403
|
-
});
|
|
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
|
-
|
|
447
|
-
expect(response.status).toBe(200);
|
|
448
|
-
expect((response.body as any).content).toBe('[memory] Hi');
|
|
449
|
-
});
|
|
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
|
-
|
|
485
|
-
it('POST /api/v1/ai/chat should return 400 without messages', async () => {
|
|
486
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
487
|
-
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
|
|
488
|
-
|
|
489
|
-
const response = await chatRoute.handler({ body: {} });
|
|
490
|
-
expect(response.status).toBe(400);
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
it('POST /api/v1/ai/chat/stream should return streaming response', async () => {
|
|
494
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
495
|
-
const streamRoute = routes.find(r => r.path === '/api/v1/ai/chat/stream')!;
|
|
496
|
-
|
|
497
|
-
const response = await streamRoute.handler({
|
|
498
|
-
body: { messages: [{ role: 'user', content: 'Hello' }] },
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
expect(response.status).toBe(200);
|
|
502
|
-
expect(response.stream).toBe(true);
|
|
503
|
-
expect(response.events).toBeDefined();
|
|
504
|
-
|
|
505
|
-
// Consume the stream
|
|
506
|
-
const events: unknown[] = [];
|
|
507
|
-
for await (const event of response.events!) {
|
|
508
|
-
events.push(event);
|
|
509
|
-
}
|
|
510
|
-
expect(events.length).toBeGreaterThan(0);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
it('POST /api/v1/ai/complete should return completion result', async () => {
|
|
514
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
515
|
-
const completeRoute = routes.find(r => r.path === '/api/v1/ai/complete')!;
|
|
516
|
-
|
|
517
|
-
const response = await completeRoute.handler({
|
|
518
|
-
body: { prompt: 'test prompt' },
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
expect(response.status).toBe(200);
|
|
522
|
-
expect((response.body as any).content).toBe('[memory] test prompt');
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
it('POST /api/v1/ai/complete should return 400 without prompt', async () => {
|
|
526
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
527
|
-
const completeRoute = routes.find(r => r.path === '/api/v1/ai/complete')!;
|
|
528
|
-
|
|
529
|
-
const response = await completeRoute.handler({ body: {} });
|
|
530
|
-
expect(response.status).toBe(400);
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
it('GET /api/v1/ai/models should return model list', async () => {
|
|
534
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
535
|
-
const modelsRoute = routes.find(r => r.path === '/api/v1/ai/models')!;
|
|
536
|
-
|
|
537
|
-
const response = await modelsRoute.handler({});
|
|
538
|
-
expect(response.status).toBe(200);
|
|
539
|
-
expect((response.body as any).models).toContain('memory');
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
it('POST /api/v1/ai/conversations should create conversation', async () => {
|
|
543
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
544
|
-
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
545
|
-
|
|
546
|
-
const response = await createRoute.handler({
|
|
547
|
-
body: { title: 'Test Conv', userId: 'u1' },
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
expect(response.status).toBe(201);
|
|
551
|
-
expect((response.body as any).title).toBe('Test Conv');
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
it('GET /api/v1/ai/conversations should list conversations', async () => {
|
|
555
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
556
|
-
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
557
|
-
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
|
|
558
|
-
|
|
559
|
-
await createRoute.handler({ body: { title: 'C1' } });
|
|
560
|
-
await createRoute.handler({ body: { title: 'C2' } });
|
|
561
|
-
|
|
562
|
-
const response = await listRoute.handler({});
|
|
563
|
-
expect(response.status).toBe(200);
|
|
564
|
-
expect((response.body as any).conversations).toHaveLength(2);
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
it('POST /api/v1/ai/conversations/:id/messages should add message', async () => {
|
|
568
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
569
|
-
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
570
|
-
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
|
|
571
|
-
|
|
572
|
-
const created = await createRoute.handler({ body: {} });
|
|
573
|
-
const convId = (created.body as any).id;
|
|
574
|
-
|
|
575
|
-
const response = await addMsgRoute.handler({
|
|
576
|
-
params: { id: convId },
|
|
577
|
-
body: { role: 'user', content: 'Hi there' },
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
expect(response.status).toBe(200);
|
|
581
|
-
expect((response.body as any).messages).toHaveLength(1);
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
it('POST /api/v1/ai/conversations/:id/messages should return 404 for unknown conversation', async () => {
|
|
585
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
586
|
-
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
|
|
587
|
-
|
|
588
|
-
const response = await addMsgRoute.handler({
|
|
589
|
-
params: { id: 'unknown' },
|
|
590
|
-
body: { role: 'user', content: 'Hi' },
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
expect(response.status).toBe(404);
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
it('DELETE /api/v1/ai/conversations/:id should delete conversation', async () => {
|
|
597
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
598
|
-
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
599
|
-
const deleteRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id')!;
|
|
600
|
-
|
|
601
|
-
const created = await createRoute.handler({ body: {} });
|
|
602
|
-
const convId = (created.body as any).id;
|
|
603
|
-
|
|
604
|
-
const response = await deleteRoute.handler({ params: { id: convId } });
|
|
605
|
-
expect(response.status).toBe(204);
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
// ── Message validation ───────────────────────────────────────
|
|
609
|
-
|
|
610
|
-
it('POST /api/v1/ai/chat should return 400 for messages with invalid role', async () => {
|
|
611
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
612
|
-
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
|
|
613
|
-
|
|
614
|
-
const response = await chatRoute.handler({
|
|
615
|
-
body: { messages: [{ role: 'invalid', content: 'Hi' }] },
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
expect(response.status).toBe(400);
|
|
619
|
-
expect((response.body as any).error).toContain('message.role');
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
it('POST /api/v1/ai/chat should return 400 for messages with non-string/non-array content', async () => {
|
|
623
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
624
|
-
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
|
|
625
|
-
|
|
626
|
-
// Numeric content should be rejected
|
|
627
|
-
const response = await chatRoute.handler({
|
|
628
|
-
body: { messages: [{ role: 'user', content: 123 }] },
|
|
629
|
-
});
|
|
630
|
-
expect(response.status).toBe(400);
|
|
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');
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
it('POST /api/v1/ai/conversations/:id/messages should return 400 for invalid role', async () => {
|
|
649
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
650
|
-
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
651
|
-
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
|
|
652
|
-
|
|
653
|
-
const created = await createRoute.handler({ body: {} });
|
|
654
|
-
const convId = (created.body as any).id;
|
|
655
|
-
|
|
656
|
-
const response = await addMsgRoute.handler({
|
|
657
|
-
params: { id: convId },
|
|
658
|
-
body: { role: 'invalid_role', content: 'Hi' },
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
expect(response.status).toBe(400);
|
|
662
|
-
expect((response.body as any).error).toContain('message.role');
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
it('POST /api/v1/ai/conversations/:id/messages should return 400 for missing content', async () => {
|
|
666
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
667
|
-
const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
|
|
668
|
-
|
|
669
|
-
const response = await addMsgRoute.handler({
|
|
670
|
-
params: { id: 'conv_1' },
|
|
671
|
-
body: { role: 'user' },
|
|
672
|
-
});
|
|
673
|
-
|
|
674
|
-
expect(response.status).toBe(400);
|
|
675
|
-
expect((response.body as any).error).toContain('content');
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
// ── Limit parsing ───────────────────────────────────────────
|
|
679
|
-
|
|
680
|
-
it('GET /api/v1/ai/conversations should parse limit from query string', async () => {
|
|
681
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
682
|
-
const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
|
|
683
|
-
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
|
|
684
|
-
|
|
685
|
-
await createRoute.handler({ body: { title: 'C1' } });
|
|
686
|
-
await createRoute.handler({ body: { title: 'C2' } });
|
|
687
|
-
await createRoute.handler({ body: { title: 'C3' } });
|
|
688
|
-
|
|
689
|
-
const response = await listRoute.handler({ query: { limit: '2' } });
|
|
690
|
-
expect(response.status).toBe(200);
|
|
691
|
-
expect((response.body as any).conversations).toHaveLength(2);
|
|
692
|
-
});
|
|
693
|
-
|
|
694
|
-
it('GET /api/v1/ai/conversations should return 400 for invalid limit', async () => {
|
|
695
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
696
|
-
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
|
|
697
|
-
|
|
698
|
-
const response = await listRoute.handler({ query: { limit: 'abc' } });
|
|
699
|
-
expect(response.status).toBe(400);
|
|
700
|
-
expect((response.body as any).error).toContain('limit');
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
it('GET /api/v1/ai/conversations should return 400 for negative limit', async () => {
|
|
704
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
705
|
-
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
|
|
706
|
-
|
|
707
|
-
const response = await listRoute.handler({ query: { limit: '-1' } });
|
|
708
|
-
expect(response.status).toBe(400);
|
|
709
|
-
expect((response.body as any).error).toContain('limit');
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
// ── Tool message in chat ────────────────────────────────────
|
|
713
|
-
|
|
714
|
-
it('POST /api/v1/ai/chat should accept tool role messages', async () => {
|
|
715
|
-
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
|
|
716
|
-
const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
|
|
717
|
-
|
|
718
|
-
const response = await chatRoute.handler({
|
|
719
|
-
body: {
|
|
720
|
-
messages: [
|
|
721
|
-
{ role: 'user', content: 'What is the weather?' },
|
|
722
|
-
{ role: 'assistant', content: '' },
|
|
723
|
-
{ role: 'tool', content: '{"temp": 22}', toolCallId: 'call_1' },
|
|
724
|
-
],
|
|
725
|
-
stream: false,
|
|
726
|
-
},
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
expect(response.status).toBe(200);
|
|
730
|
-
});
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
// ─────────────────────────────────────────────────────────────────
|
|
734
|
-
// AIServicePlugin (Integration)
|
|
735
|
-
// ─────────────────────────────────────────────────────────────────
|
|
736
|
-
|
|
737
|
-
describe('AIServicePlugin', () => {
|
|
738
|
-
function createMockContext() {
|
|
739
|
-
const services = new Map<string, unknown>();
|
|
740
|
-
const hooks = new Map<string, Function[]>();
|
|
741
|
-
|
|
742
|
-
// Pre-register manifest service
|
|
743
|
-
services.set('manifest', { register: vi.fn() });
|
|
744
|
-
|
|
745
|
-
return {
|
|
746
|
-
registerService: vi.fn((name: string, service: unknown) => services.set(name, service)),
|
|
747
|
-
replaceService: vi.fn((name: string, service: unknown) => services.set(name, service)),
|
|
748
|
-
getService: vi.fn(<T>(name: string): T => {
|
|
749
|
-
if (!services.has(name)) throw new Error(`Service "${name}" not found`);
|
|
750
|
-
return services.get(name) as T;
|
|
751
|
-
}),
|
|
752
|
-
getServices: vi.fn(() => services),
|
|
753
|
-
hook: vi.fn((name: string, handler: Function) => {
|
|
754
|
-
if (!hooks.has(name)) hooks.set(name, []);
|
|
755
|
-
hooks.get(name)!.push(handler);
|
|
756
|
-
}),
|
|
757
|
-
trigger: vi.fn(async () => {}),
|
|
758
|
-
logger: silentLogger,
|
|
759
|
-
getKernel: vi.fn(),
|
|
760
|
-
} as any;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
it('should register as "ai" service on init', async () => {
|
|
764
|
-
const plugin = new AIServicePlugin();
|
|
765
|
-
const ctx = createMockContext();
|
|
766
|
-
|
|
767
|
-
await plugin.init(ctx);
|
|
768
|
-
|
|
769
|
-
expect(ctx.registerService).toHaveBeenCalledWith('ai', expect.any(Object));
|
|
770
|
-
const service = ctx.getService<IAIService>('ai');
|
|
771
|
-
expect(service).toBeDefined();
|
|
772
|
-
expect(typeof service.chat).toBe('function');
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
it('should have correct plugin metadata', () => {
|
|
776
|
-
const plugin = new AIServicePlugin();
|
|
777
|
-
expect(plugin.name).toBe('com.objectstack.service-ai');
|
|
778
|
-
expect(plugin.version).toBe('1.0.0');
|
|
779
|
-
expect(plugin.type).toBe('standard');
|
|
780
|
-
});
|
|
781
|
-
|
|
782
|
-
it('should trigger ai:ready on start', async () => {
|
|
783
|
-
const plugin = new AIServicePlugin();
|
|
784
|
-
const ctx = createMockContext();
|
|
785
|
-
|
|
786
|
-
await plugin.init(ctx);
|
|
787
|
-
await plugin.start!(ctx);
|
|
788
|
-
|
|
789
|
-
expect(ctx.trigger).toHaveBeenCalledWith('ai:ready', expect.any(Object));
|
|
790
|
-
expect(ctx.trigger).toHaveBeenCalledWith('ai:routes', expect.any(Array));
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
it('should use custom adapter when provided', async () => {
|
|
794
|
-
const customAdapter: LLMAdapter = {
|
|
795
|
-
name: 'custom-test',
|
|
796
|
-
chat: async () => ({ content: 'custom' }),
|
|
797
|
-
complete: async () => ({ content: '' }),
|
|
798
|
-
};
|
|
799
|
-
|
|
800
|
-
const plugin = new AIServicePlugin({ adapter: customAdapter });
|
|
801
|
-
const ctx = createMockContext();
|
|
802
|
-
|
|
803
|
-
await plugin.init(ctx);
|
|
804
|
-
|
|
805
|
-
const service = ctx.getService<AIService>('ai');
|
|
806
|
-
expect(service.adapterName).toBe('custom-test');
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
it('should replace existing AI service', async () => {
|
|
810
|
-
const plugin = new AIServicePlugin();
|
|
811
|
-
const ctx = createMockContext();
|
|
812
|
-
|
|
813
|
-
// Pre-register a mock AI service
|
|
814
|
-
ctx.registerService('ai', { chat: vi.fn(), complete: vi.fn() });
|
|
815
|
-
|
|
816
|
-
await plugin.init(ctx);
|
|
817
|
-
|
|
818
|
-
expect(ctx.replaceService).toHaveBeenCalledWith('ai', expect.any(Object));
|
|
819
|
-
});
|
|
820
|
-
|
|
821
|
-
it('should clean up on destroy', async () => {
|
|
822
|
-
const plugin = new AIServicePlugin();
|
|
823
|
-
const ctx = createMockContext();
|
|
824
|
-
|
|
825
|
-
await plugin.init(ctx);
|
|
826
|
-
await plugin.destroy!();
|
|
827
|
-
|
|
828
|
-
// After destroy, the plugin should not throw
|
|
829
|
-
// (internal service reference cleared)
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
it('should register debug hook when debug=true', async () => {
|
|
833
|
-
const plugin = new AIServicePlugin({ debug: true });
|
|
834
|
-
const ctx = createMockContext();
|
|
835
|
-
|
|
836
|
-
await plugin.init(ctx);
|
|
837
|
-
|
|
838
|
-
expect(ctx.hook).toHaveBeenCalledWith('ai:beforeChat', expect.any(Function));
|
|
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
|
-
// Mock all provider SDKs to simulate them not being installed.
|
|
872
|
-
// In the workspace @ai-sdk/openai may be resolvable as a transitive
|
|
873
|
-
// dependency, so we must explicitly make the dynamic import fail.
|
|
874
|
-
vi.doMock('@ai-sdk/openai', () => { throw new Error('Cannot find module \'@ai-sdk/openai\''); });
|
|
875
|
-
vi.doMock('@ai-sdk/anthropic', () => { throw new Error('Cannot find module \'@ai-sdk/anthropic\''); });
|
|
876
|
-
vi.doMock('@ai-sdk/google', () => { throw new Error('Cannot find module \'@ai-sdk/google\''); });
|
|
877
|
-
|
|
878
|
-
// Re-import the plugin module so it picks up the mocked imports
|
|
879
|
-
const { AIServicePlugin: FreshPlugin } = await import('../plugin.js');
|
|
880
|
-
const plugin = new FreshPlugin();
|
|
881
|
-
const ctx = createMockContext();
|
|
882
|
-
|
|
883
|
-
const oldEnv = { ...process.env };
|
|
884
|
-
// Set env var, but the SDK won't be available in test environment
|
|
885
|
-
process.env.OPENAI_API_KEY = 'fake-openai-key';
|
|
886
|
-
delete process.env.AI_GATEWAY_MODEL;
|
|
887
|
-
delete process.env.ANTHROPIC_API_KEY;
|
|
888
|
-
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
889
|
-
|
|
890
|
-
try {
|
|
891
|
-
await plugin.init(ctx);
|
|
892
|
-
|
|
893
|
-
const service = ctx.getService<AIService>('ai');
|
|
894
|
-
// Should fall back to memory because @ai-sdk/openai is not installed
|
|
895
|
-
expect(service.adapterName).toBe('memory');
|
|
896
|
-
|
|
897
|
-
// Verify warning was logged about SDK load failure
|
|
898
|
-
expect(silentLogger.warn).toHaveBeenCalledWith(
|
|
899
|
-
expect.stringContaining('Failed to load @ai-sdk/openai'),
|
|
900
|
-
expect.objectContaining({ error: expect.any(String) })
|
|
901
|
-
);
|
|
902
|
-
|
|
903
|
-
// Verify warning was logged about final fallback
|
|
904
|
-
expect(silentLogger.warn).toHaveBeenCalledWith(
|
|
905
|
-
expect.stringContaining('No LLM provider configured')
|
|
906
|
-
);
|
|
907
|
-
} finally {
|
|
908
|
-
process.env = oldEnv;
|
|
909
|
-
vi.doUnmock('@ai-sdk/openai');
|
|
910
|
-
vi.doUnmock('@ai-sdk/anthropic');
|
|
911
|
-
vi.doUnmock('@ai-sdk/google');
|
|
912
|
-
}
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
it('should prefer explicit adapter over auto-detection', async () => {
|
|
916
|
-
const customAdapter: LLMAdapter = {
|
|
917
|
-
name: 'custom-explicit',
|
|
918
|
-
chat: async () => ({ content: 'explicit' }),
|
|
919
|
-
complete: async () => ({ content: '' }),
|
|
920
|
-
};
|
|
921
|
-
|
|
922
|
-
const plugin = new AIServicePlugin({ adapter: customAdapter });
|
|
923
|
-
const ctx = createMockContext();
|
|
924
|
-
|
|
925
|
-
const oldEnv = { ...process.env };
|
|
926
|
-
process.env.OPENAI_API_KEY = 'fake-openai-key';
|
|
927
|
-
|
|
928
|
-
try {
|
|
929
|
-
await plugin.init(ctx);
|
|
930
|
-
|
|
931
|
-
const service = ctx.getService<AIService>('ai');
|
|
932
|
-
expect(service.adapterName).toBe('custom-explicit');
|
|
933
|
-
|
|
934
|
-
// Verify it logged as explicitly configured
|
|
935
|
-
expect(silentLogger.info).toHaveBeenCalledWith(
|
|
936
|
-
expect.stringContaining('explicitly configured')
|
|
937
|
-
);
|
|
938
|
-
} finally {
|
|
939
|
-
process.env = oldEnv;
|
|
940
|
-
}
|
|
941
|
-
});
|
|
942
|
-
|
|
943
|
-
it('should log adapter selection', async () => {
|
|
944
|
-
const plugin = new AIServicePlugin();
|
|
945
|
-
const ctx = createMockContext();
|
|
946
|
-
|
|
947
|
-
const oldEnv = { ...process.env };
|
|
948
|
-
delete process.env.AI_GATEWAY_MODEL;
|
|
949
|
-
delete process.env.OPENAI_API_KEY;
|
|
950
|
-
delete process.env.ANTHROPIC_API_KEY;
|
|
951
|
-
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
952
|
-
|
|
953
|
-
try {
|
|
954
|
-
await plugin.init(ctx);
|
|
955
|
-
|
|
956
|
-
// Verify adapter selection was logged
|
|
957
|
-
expect(silentLogger.info).toHaveBeenCalledWith(
|
|
958
|
-
expect.stringContaining('Using LLM adapter')
|
|
959
|
-
);
|
|
960
|
-
} finally {
|
|
961
|
-
process.env = oldEnv;
|
|
962
|
-
}
|
|
963
|
-
});
|
|
964
|
-
});
|