@objectstack/service-ai 4.0.4 → 4.1.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/dist/index.cjs +1176 -134
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1752 -431
- package/dist/index.d.ts +1752 -431
- package/dist/index.js +1160 -127
- package/dist/index.js.map +1 -1
- package/package.json +35 -8
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -61
- package/src/__tests__/ai-service.test.ts +0 -981
- 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
- package/vitest.config.ts +0 -23
|
@@ -1,382 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
4
|
-
import type { IDataEngine } from '@objectstack/spec/contracts';
|
|
5
|
-
import type { ModelMessage } from '@objectstack/spec/contracts';
|
|
6
|
-
import { ObjectQLConversationService } from '../conversation/objectql-conversation-service.js';
|
|
7
|
-
|
|
8
|
-
// ─────────────────────────────────────────────────────────────────
|
|
9
|
-
// In-memory IDataEngine stub (mimics driver-memory behavior)
|
|
10
|
-
// ─────────────────────────────────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
function createMemoryEngine(): IDataEngine {
|
|
13
|
-
const tables = new Map<string, any[]>();
|
|
14
|
-
|
|
15
|
-
const getTable = (name: string) => {
|
|
16
|
-
if (!tables.has(name)) tables.set(name, []);
|
|
17
|
-
return tables.get(name)!;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
/** Evaluate a single filter condition against a row. */
|
|
21
|
-
const matchesCondition = (row: any, where: Record<string, any>): boolean => {
|
|
22
|
-
for (const [key, value] of Object.entries(where)) {
|
|
23
|
-
if (key === '$or') {
|
|
24
|
-
// At least one branch must match
|
|
25
|
-
if (!Array.isArray(value) || !value.some(branch => matchesCondition(row, branch))) {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
} else if (typeof value === 'object' && value !== null && '$gt' in value) {
|
|
29
|
-
if (!(row[key] > value.$gt)) return false;
|
|
30
|
-
} else if (row[key] !== value) {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return true;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
find: async (objectName, query?) => {
|
|
39
|
-
let rows = [...getTable(objectName)];
|
|
40
|
-
if (query?.where) {
|
|
41
|
-
rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
|
|
42
|
-
}
|
|
43
|
-
if (query?.orderBy && query.orderBy.length > 0) {
|
|
44
|
-
rows.sort((a, b) => {
|
|
45
|
-
for (const sort of query.orderBy!) {
|
|
46
|
-
const field = (sort as any).field;
|
|
47
|
-
const dir = (sort as any).order === 'desc' ? -1 : 1;
|
|
48
|
-
if (a[field] < b[field]) return -dir;
|
|
49
|
-
if (a[field] > b[field]) return dir;
|
|
50
|
-
}
|
|
51
|
-
return 0;
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
if (query?.limit) {
|
|
55
|
-
rows = rows.slice(0, query.limit);
|
|
56
|
-
}
|
|
57
|
-
return rows;
|
|
58
|
-
},
|
|
59
|
-
findOne: async (objectName, query?) => {
|
|
60
|
-
let rows = [...getTable(objectName)];
|
|
61
|
-
if (query?.where) {
|
|
62
|
-
rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
|
|
63
|
-
}
|
|
64
|
-
return rows[0] ?? null;
|
|
65
|
-
},
|
|
66
|
-
insert: async (objectName, data) => {
|
|
67
|
-
const table = getTable(objectName);
|
|
68
|
-
if (Array.isArray(data)) {
|
|
69
|
-
table.push(...data);
|
|
70
|
-
return data;
|
|
71
|
-
}
|
|
72
|
-
table.push({ ...data });
|
|
73
|
-
return data;
|
|
74
|
-
},
|
|
75
|
-
update: async (objectName, data, options?) => {
|
|
76
|
-
const table = getTable(objectName);
|
|
77
|
-
const where = options?.where as Record<string, any> | undefined;
|
|
78
|
-
for (let i = 0; i < table.length; i++) {
|
|
79
|
-
if (where) {
|
|
80
|
-
let match = true;
|
|
81
|
-
for (const [key, value] of Object.entries(where)) {
|
|
82
|
-
if (table[i][key] !== value) { match = false; break; }
|
|
83
|
-
}
|
|
84
|
-
if (!match) continue;
|
|
85
|
-
}
|
|
86
|
-
Object.assign(table[i], data);
|
|
87
|
-
return table[i];
|
|
88
|
-
}
|
|
89
|
-
return data;
|
|
90
|
-
},
|
|
91
|
-
delete: async (objectName, options?) => {
|
|
92
|
-
const table = getTable(objectName);
|
|
93
|
-
const where = options?.where as Record<string, any> | undefined;
|
|
94
|
-
let deleted = 0;
|
|
95
|
-
const multi = (options as any)?.multi ?? false;
|
|
96
|
-
for (let i = table.length - 1; i >= 0; i--) {
|
|
97
|
-
if (where) {
|
|
98
|
-
let match = true;
|
|
99
|
-
for (const [key, value] of Object.entries(where)) {
|
|
100
|
-
if (table[i][key] !== value) { match = false; break; }
|
|
101
|
-
}
|
|
102
|
-
if (!match) continue;
|
|
103
|
-
}
|
|
104
|
-
table.splice(i, 1);
|
|
105
|
-
deleted++;
|
|
106
|
-
if (!multi) break;
|
|
107
|
-
}
|
|
108
|
-
return { deleted };
|
|
109
|
-
},
|
|
110
|
-
count: async (objectName, query?) => {
|
|
111
|
-
let rows = [...getTable(objectName)];
|
|
112
|
-
if (query?.where) {
|
|
113
|
-
rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
|
|
114
|
-
}
|
|
115
|
-
return rows.length;
|
|
116
|
-
},
|
|
117
|
-
aggregate: async () => [],
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ─────────────────────────────────────────────────────────────────
|
|
122
|
-
// Tests
|
|
123
|
-
// ─────────────────────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
describe('ObjectQLConversationService', () => {
|
|
126
|
-
let engine: IDataEngine;
|
|
127
|
-
let service: ObjectQLConversationService;
|
|
128
|
-
|
|
129
|
-
beforeEach(() => {
|
|
130
|
-
engine = createMemoryEngine();
|
|
131
|
-
service = new ObjectQLConversationService(engine);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// ── create() ───────────────────────────────────────────────────
|
|
135
|
-
|
|
136
|
-
it('should create a conversation with all options', async () => {
|
|
137
|
-
const conv = await service.create({
|
|
138
|
-
title: 'Test Chat',
|
|
139
|
-
agentId: 'agent_1',
|
|
140
|
-
userId: 'user_1',
|
|
141
|
-
metadata: { source: 'web' },
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
expect(conv.id).toMatch(/^conv_/);
|
|
145
|
-
expect(conv.title).toBe('Test Chat');
|
|
146
|
-
expect(conv.agentId).toBe('agent_1');
|
|
147
|
-
expect(conv.userId).toBe('user_1');
|
|
148
|
-
expect(conv.messages).toEqual([]);
|
|
149
|
-
expect(conv.createdAt).toBeDefined();
|
|
150
|
-
expect(conv.updatedAt).toBeDefined();
|
|
151
|
-
expect(conv.metadata).toEqual({ source: 'web' });
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('should create a conversation with no options', async () => {
|
|
155
|
-
const conv = await service.create();
|
|
156
|
-
|
|
157
|
-
expect(conv.id).toMatch(/^conv_/);
|
|
158
|
-
expect(conv.title).toBeUndefined();
|
|
159
|
-
expect(conv.agentId).toBeUndefined();
|
|
160
|
-
expect(conv.userId).toBeUndefined();
|
|
161
|
-
expect(conv.messages).toEqual([]);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('should generate unique conversation IDs', async () => {
|
|
165
|
-
const c1 = await service.create({ title: 'A' });
|
|
166
|
-
const c2 = await service.create({ title: 'B' });
|
|
167
|
-
|
|
168
|
-
expect(c1.id).not.toBe(c2.id);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
// ── get() ──────────────────────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
it('should retrieve a conversation by ID', async () => {
|
|
174
|
-
const created = await service.create({ title: 'Retrieve Me' });
|
|
175
|
-
const fetched = await service.get(created.id);
|
|
176
|
-
|
|
177
|
-
expect(fetched).not.toBeNull();
|
|
178
|
-
expect(fetched!.id).toBe(created.id);
|
|
179
|
-
expect(fetched!.title).toBe('Retrieve Me');
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('should return null for non-existent conversation', async () => {
|
|
183
|
-
const result = await service.get('conv_nonexistent');
|
|
184
|
-
expect(result).toBeNull();
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// ── list() ─────────────────────────────────────────────────────
|
|
188
|
-
|
|
189
|
-
it('should list conversations filtered by userId', async () => {
|
|
190
|
-
await service.create({ userId: 'user_a' });
|
|
191
|
-
await service.create({ userId: 'user_b' });
|
|
192
|
-
await service.create({ userId: 'user_a' });
|
|
193
|
-
|
|
194
|
-
const results = await service.list({ userId: 'user_a' });
|
|
195
|
-
expect(results).toHaveLength(2);
|
|
196
|
-
results.forEach(c => expect(c.userId).toBe('user_a'));
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('should list conversations filtered by agentId', async () => {
|
|
200
|
-
await service.create({ agentId: 'bot_x' });
|
|
201
|
-
await service.create({ agentId: 'bot_y' });
|
|
202
|
-
|
|
203
|
-
const results = await service.list({ agentId: 'bot_x' });
|
|
204
|
-
expect(results).toHaveLength(1);
|
|
205
|
-
expect(results[0].agentId).toBe('bot_x');
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('should limit the number of listed conversations', async () => {
|
|
209
|
-
await service.create({ title: '1' });
|
|
210
|
-
await service.create({ title: '2' });
|
|
211
|
-
await service.create({ title: '3' });
|
|
212
|
-
|
|
213
|
-
const results = await service.list({ limit: 2 });
|
|
214
|
-
expect(results).toHaveLength(2);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it('should paginate with cursor and have no skips or duplicates', async () => {
|
|
218
|
-
await service.create({ title: 'A' });
|
|
219
|
-
await service.create({ title: 'B' });
|
|
220
|
-
await service.create({ title: 'C' });
|
|
221
|
-
await service.create({ title: 'D' });
|
|
222
|
-
|
|
223
|
-
// First page: 2 items
|
|
224
|
-
const page1 = await service.list({ limit: 2 });
|
|
225
|
-
expect(page1).toHaveLength(2);
|
|
226
|
-
|
|
227
|
-
// Second page: cursor = last item from page 1
|
|
228
|
-
const page2 = await service.list({ limit: 2, cursor: page1[1].id });
|
|
229
|
-
expect(page2).toHaveLength(2);
|
|
230
|
-
|
|
231
|
-
// Third page: should be empty
|
|
232
|
-
const page3 = await service.list({ limit: 2, cursor: page2[1].id });
|
|
233
|
-
expect(page3).toHaveLength(0);
|
|
234
|
-
|
|
235
|
-
// Verify no overlap between pages and all 4 conversations are covered
|
|
236
|
-
const allIds = [...page1, ...page2].map(c => c.id);
|
|
237
|
-
expect(new Set(allIds).size).toBe(4);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
// ── addMessage() ───────────────────────────────────────────────
|
|
241
|
-
|
|
242
|
-
it('should add a user message to a conversation', async () => {
|
|
243
|
-
const conv = await service.create({ title: 'Chat' });
|
|
244
|
-
|
|
245
|
-
const msg: ModelMessage = { role: 'user', content: 'Hello AI!' };
|
|
246
|
-
const updated = await service.addMessage(conv.id, msg);
|
|
247
|
-
|
|
248
|
-
expect(updated.messages).toHaveLength(1);
|
|
249
|
-
expect(updated.messages[0].role).toBe('user');
|
|
250
|
-
expect(updated.messages[0].content).toBe('Hello AI!');
|
|
251
|
-
expect(updated.updatedAt >= conv.updatedAt).toBe(true);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it('should add a tool message with toolCallId', async () => {
|
|
255
|
-
const conv = await service.create();
|
|
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
|
-
}],
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
const updated = await service.addMessage(conv.id, msg);
|
|
267
|
-
expect(updated.messages).toHaveLength(1);
|
|
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
|
-
}
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it('should add an assistant message with toolCalls', async () => {
|
|
277
|
-
const conv = await service.create();
|
|
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
|
-
],
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
const updated = await service.addMessage(conv.id, msg);
|
|
286
|
-
expect(updated.messages).toHaveLength(1);
|
|
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
|
-
}
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it('should throw when adding message to non-existent conversation', async () => {
|
|
298
|
-
const msg: ModelMessage = { role: 'user', content: 'Hello' };
|
|
299
|
-
await expect(service.addMessage('conv_ghost', msg)).rejects.toThrow(
|
|
300
|
-
'Conversation "conv_ghost" not found',
|
|
301
|
-
);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('should preserve message order (ordered by createdAt + id)', async () => {
|
|
305
|
-
const conv = await service.create();
|
|
306
|
-
await service.addMessage(conv.id, { role: 'user', content: 'First' });
|
|
307
|
-
await service.addMessage(conv.id, { role: 'assistant', content: 'Second' });
|
|
308
|
-
await service.addMessage(conv.id, { role: 'user', content: 'Third' });
|
|
309
|
-
|
|
310
|
-
const fetched = await service.get(conv.id);
|
|
311
|
-
expect(fetched!.messages).toHaveLength(3);
|
|
312
|
-
// All three messages should be present
|
|
313
|
-
const contents = fetched!.messages.map(m => m.content);
|
|
314
|
-
expect(contents).toContain('First');
|
|
315
|
-
expect(contents).toContain('Second');
|
|
316
|
-
expect(contents).toContain('Third');
|
|
317
|
-
// Ordering is deterministic (created_at asc, id asc)
|
|
318
|
-
// Since messages are inserted sequentially, created_at is non-decreasing
|
|
319
|
-
for (let i = 1; i < fetched!.messages.length; i++) {
|
|
320
|
-
const prev = fetched!.messages[i - 1];
|
|
321
|
-
const curr = fetched!.messages[i];
|
|
322
|
-
// Verify stable ordering: each message is >= the previous by (created_at, id)
|
|
323
|
-
expect(prev.content).toBeDefined();
|
|
324
|
-
expect(curr.content).toBeDefined();
|
|
325
|
-
}
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
// ── delete() ───────────────────────────────────────────────────
|
|
329
|
-
|
|
330
|
-
it('should delete a conversation and its messages', async () => {
|
|
331
|
-
const conv = await service.create({ title: 'Delete Me' });
|
|
332
|
-
await service.addMessage(conv.id, { role: 'user', content: 'Bye' });
|
|
333
|
-
|
|
334
|
-
await service.delete(conv.id);
|
|
335
|
-
|
|
336
|
-
const result = await service.get(conv.id);
|
|
337
|
-
expect(result).toBeNull();
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
it('should handle deleting a non-existent conversation gracefully', async () => {
|
|
341
|
-
// Should not throw
|
|
342
|
-
await expect(service.delete('conv_missing')).resolves.toBeUndefined();
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
// ── metadata serialization round-trip ──────────────────────────
|
|
346
|
-
|
|
347
|
-
it('should round-trip metadata through JSON serialization', async () => {
|
|
348
|
-
const metadata = { tags: ['important', 'follow-up'], priority: 1 };
|
|
349
|
-
const conv = await service.create({ metadata });
|
|
350
|
-
|
|
351
|
-
const fetched = await service.get(conv.id);
|
|
352
|
-
expect(fetched!.metadata).toEqual(metadata);
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
// ── invalid JSON resilience ────────────────────────────────────
|
|
356
|
-
|
|
357
|
-
it('should handle invalid JSON in metadata gracefully', async () => {
|
|
358
|
-
const conv = await service.create({ title: 'Bad Meta' });
|
|
359
|
-
|
|
360
|
-
// Manually corrupt the metadata in the engine
|
|
361
|
-
const rows = await engine.find('ai_conversations', { where: { id: conv.id } });
|
|
362
|
-
rows[0].metadata = 'not-valid-json{';
|
|
363
|
-
|
|
364
|
-
const fetched = await service.get(conv.id);
|
|
365
|
-
expect(fetched).not.toBeNull();
|
|
366
|
-
expect(fetched!.metadata).toBeUndefined();
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it('should handle invalid JSON in tool_calls gracefully', async () => {
|
|
370
|
-
const conv = await service.create();
|
|
371
|
-
await service.addMessage(conv.id, { role: 'assistant', content: 'checking tools' });
|
|
372
|
-
|
|
373
|
-
// Manually corrupt tool_calls in the engine
|
|
374
|
-
const msgs = await engine.find('ai_messages', { where: { conversation_id: conv.id } });
|
|
375
|
-
msgs[0].tool_calls = 'broken{json';
|
|
376
|
-
|
|
377
|
-
const fetched = await service.get(conv.id);
|
|
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');
|
|
381
|
-
});
|
|
382
|
-
});
|
|
@@ -1,191 +0,0 @@
|
|
|
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
|
-
});
|