@objectstack/service-ai 4.0.1 → 4.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +17 -0
- package/dist/index.cjs +1632 -355
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +330 -87
- package/dist/index.d.ts +330 -87
- package/dist/index.js +1623 -352
- package/dist/index.js.map +1 -1
- package/package.json +27 -5
- package/src/__tests__/ai-service.test.ts +260 -27
- package/src/__tests__/auth-and-toolcalling.test.ts +81 -29
- package/src/__tests__/chatbot-features.test.ts +397 -102
- package/src/__tests__/metadata-tools.test.ts +970 -0
- package/src/__tests__/objectql-conversation-service.test.ts +34 -16
- package/src/__tests__/tool-routes.test.ts +191 -0
- package/src/__tests__/vercel-stream-encoder.test.ts +310 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/memory-adapter.ts +17 -9
- package/src/adapters/vercel-adapter.ts +148 -0
- package/src/agent-runtime.ts +27 -3
- package/src/agents/index.ts +1 -0
- package/src/agents/metadata-assistant-agent.ts +87 -0
- package/src/ai-service.ts +75 -36
- package/src/conversation/in-memory-conversation-service.ts +2 -2
- package/src/conversation/objectql-conversation-service.ts +67 -18
- package/src/index.ts +22 -2
- package/src/plugin.ts +237 -30
- package/src/routes/agent-routes.ts +68 -12
- package/src/routes/ai-routes.ts +93 -14
- package/src/routes/index.ts +1 -0
- package/src/routes/message-utils.ts +90 -0
- package/src/routes/tool-routes.ts +142 -0
- package/src/stream/index.ts +3 -0
- package/src/stream/vercel-stream-encoder.ts +153 -0
- package/src/tools/add-field.tool.ts +70 -0
- package/src/tools/create-object.tool.ts +66 -0
- package/src/tools/data-tools.ts +4 -101
- package/src/tools/delete-field.tool.ts +38 -0
- package/src/tools/describe-object.tool.ts +31 -0
- package/src/tools/index.ts +12 -1
- package/src/tools/list-objects.tool.ts +34 -0
- package/src/tools/metadata-tools.ts +430 -0
- package/src/tools/modify-field.tool.ts +44 -0
- package/src/tools/tool-registry.ts +32 -9
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
4
|
import type {
|
|
5
|
-
|
|
5
|
+
ModelMessage,
|
|
6
6
|
AIResult,
|
|
7
7
|
AIRequestOptions,
|
|
8
|
-
|
|
8
|
+
ToolCallPart,
|
|
9
9
|
AIToolDefinition,
|
|
10
10
|
IDataEngine,
|
|
11
11
|
IMetadataService,
|
|
@@ -19,6 +19,7 @@ import { AgentRuntime } from '../agent-runtime.js';
|
|
|
19
19
|
import type { AgentChatContext } from '../agent-runtime.js';
|
|
20
20
|
import { buildAgentRoutes } from '../routes/agent-routes.js';
|
|
21
21
|
import { DATA_CHAT_AGENT } from '../agents/data-chat-agent.js';
|
|
22
|
+
import { METADATA_ASSISTANT_AGENT } from '../agents/metadata-assistant-agent.js';
|
|
22
23
|
|
|
23
24
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
24
25
|
|
|
@@ -108,10 +109,11 @@ describe('AIService.chatWithTools', () => {
|
|
|
108
109
|
});
|
|
109
110
|
|
|
110
111
|
it('should execute tool calls and loop until final text response', async () => {
|
|
111
|
-
const toolCall:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
const toolCall: ToolCallPart = {
|
|
113
|
+
type: 'tool-call' as const,
|
|
114
|
+
toolCallId: 'call_1',
|
|
115
|
+
toolName: 'get_weather',
|
|
116
|
+
input: { city: 'Tokyo' },
|
|
115
117
|
};
|
|
116
118
|
|
|
117
119
|
const adapter = createMockAdapter([
|
|
@@ -131,13 +133,16 @@ describe('AIService.chatWithTools', () => {
|
|
|
131
133
|
expect(adapter.chat).toHaveBeenCalledTimes(2);
|
|
132
134
|
|
|
133
135
|
// Verify the second call includes the tool result message
|
|
134
|
-
const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as
|
|
136
|
+
const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[];
|
|
135
137
|
expect(secondCallMessages).toHaveLength(3); // user + assistant(tool_call) + tool(result)
|
|
136
138
|
expect(secondCallMessages[1].role).toBe('assistant');
|
|
137
|
-
|
|
139
|
+
const assistantContent = secondCallMessages[1].content as any[];
|
|
140
|
+
const toolCallParts = assistantContent.filter((p: any) => p.type === 'tool-call');
|
|
141
|
+
expect(toolCallParts).toEqual([toolCall]);
|
|
138
142
|
expect(secondCallMessages[2].role).toBe('tool');
|
|
139
|
-
|
|
140
|
-
expect(
|
|
143
|
+
const toolResultContent = secondCallMessages[2].content as any[];
|
|
144
|
+
expect(toolResultContent[0].toolCallId).toBe('call_1');
|
|
145
|
+
expect(toolResultContent[0].output.value).toContain('"temp":22');
|
|
141
146
|
});
|
|
142
147
|
|
|
143
148
|
it('should handle multiple sequential tool calls', async () => {
|
|
@@ -148,9 +153,9 @@ describe('AIService.chatWithTools', () => {
|
|
|
148
153
|
|
|
149
154
|
const adapter = createMockAdapter([
|
|
150
155
|
// Round 1: call get_weather
|
|
151
|
-
{ content: '', toolCalls: [{
|
|
156
|
+
{ content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'get_weather', input: { city: 'NYC' } }] },
|
|
152
157
|
// Round 2: call get_time
|
|
153
|
-
{ content: '', toolCalls: [{
|
|
158
|
+
{ content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c2', toolName: 'get_time', input: {} }] },
|
|
154
159
|
// Round 3: final response
|
|
155
160
|
{ content: 'NYC: 22°C at 14:30' },
|
|
156
161
|
]);
|
|
@@ -173,8 +178,8 @@ describe('AIService.chatWithTools', () => {
|
|
|
173
178
|
{
|
|
174
179
|
content: '',
|
|
175
180
|
toolCalls: [
|
|
176
|
-
{
|
|
177
|
-
{
|
|
181
|
+
{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'get_weather', input: { city: 'London' } },
|
|
182
|
+
{ type: 'tool-call' as const, toolCallId: 'c2', toolName: 'get_population', input: { city: 'London' } },
|
|
178
183
|
],
|
|
179
184
|
},
|
|
180
185
|
// Final response with both results
|
|
@@ -187,7 +192,7 @@ describe('AIService.chatWithTools', () => {
|
|
|
187
192
|
expect(result.content).toBe('London: 22°C, pop 1M');
|
|
188
193
|
|
|
189
194
|
// Both tool results should be in the conversation
|
|
190
|
-
const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as
|
|
195
|
+
const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[];
|
|
191
196
|
const toolMessages = secondCallMessages.filter(m => m.role === 'tool');
|
|
192
197
|
expect(toolMessages).toHaveLength(2);
|
|
193
198
|
});
|
|
@@ -196,7 +201,7 @@ describe('AIService.chatWithTools', () => {
|
|
|
196
201
|
// Adapter always returns tool calls — would loop forever
|
|
197
202
|
const infiniteToolCall: AIResult = {
|
|
198
203
|
content: '',
|
|
199
|
-
toolCalls: [{
|
|
204
|
+
toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'get_weather', input: { city: 'X' } }],
|
|
200
205
|
};
|
|
201
206
|
const adapter = createMockAdapter(
|
|
202
207
|
Array(5).fill(infiniteToolCall).concat([{ content: 'Forced stop' }]),
|
|
@@ -243,7 +248,7 @@ describe('AIService.chatWithTools', () => {
|
|
|
243
248
|
);
|
|
244
249
|
|
|
245
250
|
const adapter = createMockAdapter([
|
|
246
|
-
{ content: '', toolCalls: [{
|
|
251
|
+
{ content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'bad_tool', input: {} }] },
|
|
247
252
|
{ content: 'I see the tool failed' },
|
|
248
253
|
]);
|
|
249
254
|
|
|
@@ -253,9 +258,16 @@ describe('AIService.chatWithTools', () => {
|
|
|
253
258
|
expect(result.content).toBe('I see the tool failed');
|
|
254
259
|
|
|
255
260
|
// The error message should be in the tool result
|
|
256
|
-
const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as
|
|
261
|
+
const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[];
|
|
257
262
|
const toolMsg = secondCallMessages.find(m => m.role === 'tool');
|
|
258
|
-
|
|
263
|
+
let toolContent: string | undefined;
|
|
264
|
+
if (toolMsg?.role === 'tool' && Array.isArray(toolMsg.content)) {
|
|
265
|
+
const firstResult = toolMsg.content[0];
|
|
266
|
+
if ('output' in firstResult && firstResult.output && typeof firstResult.output === 'object' && 'value' in firstResult.output) {
|
|
267
|
+
toolContent = String(firstResult.output.value);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
expect(toolContent).toContain('Tool crashed');
|
|
259
271
|
});
|
|
260
272
|
|
|
261
273
|
it('should work with no registered tools', async () => {
|
|
@@ -292,15 +304,13 @@ describe('AIService.chatWithTools', () => {
|
|
|
292
304
|
|
|
293
305
|
describe('Data Tools', () => {
|
|
294
306
|
describe('DATA_TOOL_DEFINITIONS', () => {
|
|
295
|
-
it('should define exactly
|
|
296
|
-
expect(DATA_TOOL_DEFINITIONS).toHaveLength(
|
|
307
|
+
it('should define exactly 3 tools', () => {
|
|
308
|
+
expect(DATA_TOOL_DEFINITIONS).toHaveLength(3);
|
|
297
309
|
});
|
|
298
310
|
|
|
299
311
|
it('should include all expected tool names', () => {
|
|
300
312
|
const names = DATA_TOOL_DEFINITIONS.map(t => t.name);
|
|
301
313
|
expect(names).toEqual([
|
|
302
|
-
'list_objects',
|
|
303
|
-
'describe_object',
|
|
304
314
|
'query_records',
|
|
305
315
|
'get_record',
|
|
306
316
|
'aggregate_data',
|
|
@@ -324,36 +334,43 @@ describe('Data Tools', () => {
|
|
|
324
334
|
registry = new ToolRegistry();
|
|
325
335
|
dataEngine = createMockDataEngine();
|
|
326
336
|
metadataService = createMockMetadataService();
|
|
327
|
-
registerDataTools(registry, { dataEngine
|
|
337
|
+
registerDataTools(registry, { dataEngine });
|
|
328
338
|
});
|
|
329
339
|
|
|
330
|
-
it('should register all
|
|
331
|
-
expect(registry.size).toBe(
|
|
332
|
-
expect(registry.has('list_objects')).toBe(true);
|
|
333
|
-
expect(registry.has('describe_object')).toBe(true);
|
|
340
|
+
it('should register all 3 tools', () => {
|
|
341
|
+
expect(registry.size).toBe(3);
|
|
334
342
|
expect(registry.has('query_records')).toBe(true);
|
|
335
343
|
expect(registry.has('get_record')).toBe(true);
|
|
336
344
|
expect(registry.has('aggregate_data')).toBe(true);
|
|
337
345
|
});
|
|
338
346
|
|
|
339
|
-
it('list_objects should return object names and labels', async () => {
|
|
347
|
+
it('list_objects should return object names and labels (via metadata tools)', async () => {
|
|
348
|
+
// list_objects is now part of metadata tools — register them
|
|
349
|
+
const { registerMetadataTools } = await import('../tools/metadata-tools.js');
|
|
350
|
+
registerMetadataTools(registry, { metadataService });
|
|
351
|
+
|
|
340
352
|
(metadataService.listObjects as any).mockResolvedValue([
|
|
341
|
-
{ name: 'account', label: 'Account' },
|
|
342
|
-
{ name: 'contact', label: 'Contact' },
|
|
353
|
+
{ name: 'account', label: 'Account', fields: { name: { type: 'text' } } },
|
|
354
|
+
{ name: 'contact', label: 'Contact', fields: {} },
|
|
343
355
|
]);
|
|
344
356
|
|
|
345
357
|
const result = await registry.execute({
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
358
|
+
type: 'tool-call' as const,
|
|
359
|
+
toolCallId: 'c1',
|
|
360
|
+
toolName: 'list_objects',
|
|
361
|
+
input: {},
|
|
349
362
|
});
|
|
350
363
|
|
|
351
|
-
const parsed = JSON.parse(result.
|
|
352
|
-
expect(parsed).toHaveLength(2);
|
|
353
|
-
expect(parsed[0]).toEqual({ name: 'account', label: 'Account' });
|
|
364
|
+
const parsed = JSON.parse((result.output as any).value);
|
|
365
|
+
expect(parsed.objects).toHaveLength(2);
|
|
366
|
+
expect(parsed.objects[0]).toEqual(expect.objectContaining({ name: 'account', label: 'Account' }));
|
|
354
367
|
});
|
|
355
368
|
|
|
356
|
-
it('describe_object should return field schema', async () => {
|
|
369
|
+
it('describe_object should return field schema (via metadata tools)', async () => {
|
|
370
|
+
// describe_object is now part of metadata tools — register them
|
|
371
|
+
const { registerMetadataTools } = await import('../tools/metadata-tools.js');
|
|
372
|
+
registerMetadataTools(registry, { metadataService });
|
|
373
|
+
|
|
357
374
|
(metadataService.getObject as any).mockResolvedValue({
|
|
358
375
|
name: 'account',
|
|
359
376
|
label: 'Account',
|
|
@@ -364,26 +381,35 @@ describe('Data Tools', () => {
|
|
|
364
381
|
});
|
|
365
382
|
|
|
366
383
|
const result = await registry.execute({
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
384
|
+
type: 'tool-call' as const,
|
|
385
|
+
toolCallId: 'c1',
|
|
386
|
+
toolName: 'describe_object',
|
|
387
|
+
input: { objectName: 'account' },
|
|
370
388
|
});
|
|
371
389
|
|
|
372
|
-
const parsed = JSON.parse(result.
|
|
390
|
+
const parsed = JSON.parse((result.output as any).value);
|
|
373
391
|
expect(parsed.name).toBe('account');
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
expect(
|
|
392
|
+
// Unified handler returns fields as array (not object)
|
|
393
|
+
const nameField = parsed.fields.find((f: any) => f.name === 'name');
|
|
394
|
+
expect(nameField.type).toBe('text');
|
|
395
|
+
expect(nameField.required).toBe(true);
|
|
396
|
+
const revenueField = parsed.fields.find((f: any) => f.name === 'revenue');
|
|
397
|
+
expect(revenueField.type).toBe('number');
|
|
377
398
|
});
|
|
378
399
|
|
|
379
|
-
it('describe_object should return error for unknown object', async () => {
|
|
400
|
+
it('describe_object should return error for unknown object (via metadata tools)', async () => {
|
|
401
|
+
// describe_object is now part of metadata tools — register them
|
|
402
|
+
const { registerMetadataTools } = await import('../tools/metadata-tools.js');
|
|
403
|
+
registerMetadataTools(registry, { metadataService });
|
|
404
|
+
|
|
380
405
|
const result = await registry.execute({
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
406
|
+
type: 'tool-call' as const,
|
|
407
|
+
toolCallId: 'c1',
|
|
408
|
+
toolName: 'describe_object',
|
|
409
|
+
input: { objectName: 'nonexistent' },
|
|
384
410
|
});
|
|
385
411
|
|
|
386
|
-
const parsed = JSON.parse(result.
|
|
412
|
+
const parsed = JSON.parse((result.output as any).value);
|
|
387
413
|
expect(parsed.error).toContain('not found');
|
|
388
414
|
});
|
|
389
415
|
|
|
@@ -392,14 +418,15 @@ describe('Data Tools', () => {
|
|
|
392
418
|
(dataEngine.find as any).mockResolvedValue(records);
|
|
393
419
|
|
|
394
420
|
const result = await registry.execute({
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
421
|
+
type: 'tool-call' as const,
|
|
422
|
+
toolCallId: 'c1',
|
|
423
|
+
toolName: 'query_records',
|
|
424
|
+
input: {
|
|
398
425
|
objectName: 'account',
|
|
399
426
|
where: { status: 'active' },
|
|
400
427
|
fields: ['name', 'status'],
|
|
401
428
|
limit: 10,
|
|
402
|
-
}
|
|
429
|
+
},
|
|
403
430
|
});
|
|
404
431
|
|
|
405
432
|
expect(dataEngine.find).toHaveBeenCalledWith('account', {
|
|
@@ -410,7 +437,7 @@ describe('Data Tools', () => {
|
|
|
410
437
|
offset: undefined,
|
|
411
438
|
});
|
|
412
439
|
|
|
413
|
-
const parsed = JSON.parse(result.
|
|
440
|
+
const parsed = JSON.parse((result.output as any).value);
|
|
414
441
|
expect(parsed.count).toBe(2);
|
|
415
442
|
expect(parsed.records).toEqual(records);
|
|
416
443
|
});
|
|
@@ -419,9 +446,10 @@ describe('Data Tools', () => {
|
|
|
419
446
|
(dataEngine.find as any).mockResolvedValue([]);
|
|
420
447
|
|
|
421
448
|
await registry.execute({
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
449
|
+
type: 'tool-call' as const,
|
|
450
|
+
toolCallId: 'c1',
|
|
451
|
+
toolName: 'query_records',
|
|
452
|
+
input: { objectName: 'account', limit: 999 },
|
|
425
453
|
});
|
|
426
454
|
|
|
427
455
|
expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({
|
|
@@ -433,9 +461,10 @@ describe('Data Tools', () => {
|
|
|
433
461
|
(dataEngine.find as any).mockResolvedValue([]);
|
|
434
462
|
|
|
435
463
|
await registry.execute({
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
464
|
+
type: 'tool-call' as const,
|
|
465
|
+
toolCallId: 'c1',
|
|
466
|
+
toolName: 'query_records',
|
|
467
|
+
input: { objectName: 'account' },
|
|
439
468
|
});
|
|
440
469
|
|
|
441
470
|
expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({
|
|
@@ -448,9 +477,10 @@ describe('Data Tools', () => {
|
|
|
448
477
|
(dataEngine.findOne as any).mockResolvedValue(record);
|
|
449
478
|
|
|
450
479
|
const result = await registry.execute({
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
480
|
+
type: 'tool-call' as const,
|
|
481
|
+
toolCallId: 'c1',
|
|
482
|
+
toolName: 'get_record',
|
|
483
|
+
input: { objectName: 'account', recordId: 'rec_123' },
|
|
454
484
|
});
|
|
455
485
|
|
|
456
486
|
expect(dataEngine.findOne).toHaveBeenCalledWith('account', {
|
|
@@ -458,18 +488,19 @@ describe('Data Tools', () => {
|
|
|
458
488
|
fields: undefined,
|
|
459
489
|
});
|
|
460
490
|
|
|
461
|
-
const parsed = JSON.parse(result.
|
|
491
|
+
const parsed = JSON.parse((result.output as any).value);
|
|
462
492
|
expect(parsed.name).toBe('Acme Corp');
|
|
463
493
|
});
|
|
464
494
|
|
|
465
495
|
it('get_record should return error for missing record', async () => {
|
|
466
496
|
const result = await registry.execute({
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
497
|
+
type: 'tool-call' as const,
|
|
498
|
+
toolCallId: 'c1',
|
|
499
|
+
toolName: 'get_record',
|
|
500
|
+
input: { objectName: 'account', recordId: 'not_found' },
|
|
470
501
|
});
|
|
471
502
|
|
|
472
|
-
const parsed = JSON.parse(result.
|
|
503
|
+
const parsed = JSON.parse((result.output as any).value);
|
|
473
504
|
expect(parsed.error).toContain('not found');
|
|
474
505
|
});
|
|
475
506
|
|
|
@@ -478,13 +509,14 @@ describe('Data Tools', () => {
|
|
|
478
509
|
(dataEngine.aggregate as any).mockResolvedValue(aggResult);
|
|
479
510
|
|
|
480
511
|
const result = await registry.execute({
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
512
|
+
type: 'tool-call' as const,
|
|
513
|
+
toolCallId: 'c1',
|
|
514
|
+
toolName: 'aggregate_data',
|
|
515
|
+
input: {
|
|
484
516
|
objectName: 'account',
|
|
485
517
|
aggregations: [{ function: 'sum', field: 'revenue', alias: 'total_revenue' }],
|
|
486
518
|
where: { status: 'active' },
|
|
487
|
-
}
|
|
519
|
+
},
|
|
488
520
|
});
|
|
489
521
|
|
|
490
522
|
expect(dataEngine.aggregate).toHaveBeenCalledWith('account', {
|
|
@@ -493,21 +525,22 @@ describe('Data Tools', () => {
|
|
|
493
525
|
aggregations: [{ function: 'sum', field: 'revenue', alias: 'total_revenue' }],
|
|
494
526
|
});
|
|
495
527
|
|
|
496
|
-
const parsed = JSON.parse(result.
|
|
528
|
+
const parsed = JSON.parse((result.output as any).value);
|
|
497
529
|
expect(parsed).toEqual(aggResult);
|
|
498
530
|
});
|
|
499
531
|
|
|
500
532
|
it('aggregate_data should reject invalid aggregation functions', async () => {
|
|
501
533
|
const result = await registry.execute({
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
534
|
+
type: 'tool-call' as const,
|
|
535
|
+
toolCallId: 'c1',
|
|
536
|
+
toolName: 'aggregate_data',
|
|
537
|
+
input: {
|
|
505
538
|
objectName: 'account',
|
|
506
539
|
aggregations: [{ function: 'drop_table', field: 'id', alias: 'x' }],
|
|
507
|
-
}
|
|
540
|
+
},
|
|
508
541
|
});
|
|
509
542
|
|
|
510
|
-
const parsed = JSON.parse(result.
|
|
543
|
+
const parsed = JSON.parse((result.output as any).value);
|
|
511
544
|
expect(parsed.error).toContain('Invalid aggregation function');
|
|
512
545
|
expect(parsed.error).toContain('drop_table');
|
|
513
546
|
expect(dataEngine.aggregate).not.toHaveBeenCalled();
|
|
@@ -517,9 +550,10 @@ describe('Data Tools', () => {
|
|
|
517
550
|
(dataEngine.find as any).mockResolvedValue([]);
|
|
518
551
|
|
|
519
552
|
await registry.execute({
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
553
|
+
type: 'tool-call' as const,
|
|
554
|
+
toolCallId: 'c1',
|
|
555
|
+
toolName: 'query_records',
|
|
556
|
+
input: { objectName: 'account', limit: -5 },
|
|
523
557
|
});
|
|
524
558
|
|
|
525
559
|
expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({
|
|
@@ -531,9 +565,10 @@ describe('Data Tools', () => {
|
|
|
531
565
|
(dataEngine.find as any).mockResolvedValue([]);
|
|
532
566
|
|
|
533
567
|
await registry.execute({
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
568
|
+
type: 'tool-call' as const,
|
|
569
|
+
toolCallId: 'c1',
|
|
570
|
+
toolName: 'query_records',
|
|
571
|
+
input: { objectName: 'account', limit: 'not_a_number' },
|
|
537
572
|
});
|
|
538
573
|
|
|
539
574
|
expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({
|
|
@@ -545,9 +580,10 @@ describe('Data Tools', () => {
|
|
|
545
580
|
(dataEngine.find as any).mockResolvedValue([]);
|
|
546
581
|
|
|
547
582
|
await registry.execute({
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
583
|
+
type: 'tool-call' as const,
|
|
584
|
+
toolCallId: 'c1',
|
|
585
|
+
toolName: 'query_records',
|
|
586
|
+
input: { objectName: 'account', offset: -10 },
|
|
551
587
|
});
|
|
552
588
|
|
|
553
589
|
expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({
|
|
@@ -655,6 +691,45 @@ describe('AgentRuntime', () => {
|
|
|
655
691
|
expect(options.model).toBeUndefined();
|
|
656
692
|
});
|
|
657
693
|
});
|
|
694
|
+
|
|
695
|
+
describe('listAgents', () => {
|
|
696
|
+
it('should return summaries of all active agents', async () => {
|
|
697
|
+
(metadataService.list as any).mockResolvedValue([
|
|
698
|
+
DATA_CHAT_AGENT,
|
|
699
|
+
METADATA_ASSISTANT_AGENT,
|
|
700
|
+
]);
|
|
701
|
+
const agents = await runtime.listAgents();
|
|
702
|
+
expect(agents).toHaveLength(2);
|
|
703
|
+
expect(agents[0]).toEqual({ name: 'data_chat', label: 'Data Assistant', role: 'Business Data Analyst' });
|
|
704
|
+
expect(agents[1]).toEqual({ name: 'metadata_assistant', label: 'Metadata Assistant', role: 'Schema Architect' });
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('should filter out inactive agents', async () => {
|
|
708
|
+
(metadataService.list as any).mockResolvedValue([
|
|
709
|
+
DATA_CHAT_AGENT,
|
|
710
|
+
{ ...METADATA_ASSISTANT_AGENT, active: false },
|
|
711
|
+
]);
|
|
712
|
+
const agents = await runtime.listAgents();
|
|
713
|
+
expect(agents).toHaveLength(1);
|
|
714
|
+
expect(agents[0].name).toBe('data_chat');
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('should return empty array when no agents registered', async () => {
|
|
718
|
+
(metadataService.list as any).mockResolvedValue([]);
|
|
719
|
+
const agents = await runtime.listAgents();
|
|
720
|
+
expect(agents).toEqual([]);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('should skip malformed agent metadata', async () => {
|
|
724
|
+
(metadataService.list as any).mockResolvedValue([
|
|
725
|
+
DATA_CHAT_AGENT,
|
|
726
|
+
{ name: 'bad', label: 'Bad' }, // missing required fields
|
|
727
|
+
]);
|
|
728
|
+
const agents = await runtime.listAgents();
|
|
729
|
+
expect(agents).toHaveLength(1);
|
|
730
|
+
expect(agents[0].name).toBe('data_chat');
|
|
731
|
+
});
|
|
732
|
+
});
|
|
658
733
|
});
|
|
659
734
|
|
|
660
735
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -677,19 +752,37 @@ describe('Agent Routes', () => {
|
|
|
677
752
|
if (name === 'inactive_agent') return { ...DATA_CHAT_AGENT, name: 'inactive_agent', active: false };
|
|
678
753
|
return undefined;
|
|
679
754
|
}),
|
|
755
|
+
list: vi.fn(async () => [DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT]),
|
|
680
756
|
});
|
|
681
757
|
runtime = new AgentRuntime(metadataService);
|
|
682
758
|
routes = buildAgentRoutes(aiService, runtime, silentLogger);
|
|
683
759
|
});
|
|
684
760
|
|
|
685
|
-
it('should define
|
|
686
|
-
expect(routes).toHaveLength(
|
|
687
|
-
expect(routes[0].method).toBe('
|
|
688
|
-
expect(routes[0].path).toBe('/api/v1/ai/agents
|
|
761
|
+
it('should define a GET list route and a POST chat route', () => {
|
|
762
|
+
expect(routes).toHaveLength(2);
|
|
763
|
+
expect(routes[0].method).toBe('GET');
|
|
764
|
+
expect(routes[0].path).toBe('/api/v1/ai/agents');
|
|
765
|
+
expect(routes[1].method).toBe('POST');
|
|
766
|
+
expect(routes[1].path).toBe('/api/v1/ai/agents/:agentName/chat');
|
|
689
767
|
});
|
|
690
768
|
|
|
769
|
+
// ── GET /api/v1/ai/agents ──
|
|
770
|
+
|
|
771
|
+
it('should return list of active agents', async () => {
|
|
772
|
+
const listRoute = routes.find(r => r.method === 'GET')!;
|
|
773
|
+
const resp = await listRoute.handler({});
|
|
774
|
+
expect(resp.status).toBe(200);
|
|
775
|
+
const body = resp.body as { agents: Array<{ name: string; label: string; role: string }> };
|
|
776
|
+
expect(body.agents).toHaveLength(2);
|
|
777
|
+
expect(body.agents[0].name).toBe('data_chat');
|
|
778
|
+
expect(body.agents[1].name).toBe('metadata_assistant');
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// ── POST /api/v1/ai/agents/:agentName/chat ──
|
|
782
|
+
|
|
691
783
|
it('should return 400 if agentName is missing', async () => {
|
|
692
|
-
const
|
|
784
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
785
|
+
const resp = await chatRoute.handler({
|
|
693
786
|
params: {},
|
|
694
787
|
body: { messages: [{ role: 'user', content: 'Hi' }] },
|
|
695
788
|
});
|
|
@@ -697,7 +790,8 @@ describe('Agent Routes', () => {
|
|
|
697
790
|
});
|
|
698
791
|
|
|
699
792
|
it('should return 400 if messages is empty', async () => {
|
|
700
|
-
const
|
|
793
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
794
|
+
const resp = await chatRoute.handler({
|
|
701
795
|
params: { agentName: 'data_chat' },
|
|
702
796
|
body: { messages: [] },
|
|
703
797
|
});
|
|
@@ -705,7 +799,8 @@ describe('Agent Routes', () => {
|
|
|
705
799
|
});
|
|
706
800
|
|
|
707
801
|
it('should return 404 for unknown agent', async () => {
|
|
708
|
-
const
|
|
802
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
803
|
+
const resp = await chatRoute.handler({
|
|
709
804
|
params: { agentName: 'unknown_agent' },
|
|
710
805
|
body: { messages: [{ role: 'user', content: 'Hi' }] },
|
|
711
806
|
});
|
|
@@ -714,7 +809,8 @@ describe('Agent Routes', () => {
|
|
|
714
809
|
});
|
|
715
810
|
|
|
716
811
|
it('should return 403 for inactive agent', async () => {
|
|
717
|
-
const
|
|
812
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
813
|
+
const resp = await chatRoute.handler({
|
|
718
814
|
params: { agentName: 'inactive_agent' },
|
|
719
815
|
body: { messages: [{ role: 'user', content: 'Hi' }] },
|
|
720
816
|
});
|
|
@@ -722,12 +818,14 @@ describe('Agent Routes', () => {
|
|
|
722
818
|
expect((resp.body as any).error).toContain('not active');
|
|
723
819
|
});
|
|
724
820
|
|
|
725
|
-
it('should return 200 with agent response for valid request', async () => {
|
|
726
|
-
const
|
|
821
|
+
it('should return 200 with agent response for valid request (stream=false)', async () => {
|
|
822
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
823
|
+
const resp = await chatRoute.handler({
|
|
727
824
|
params: { agentName: 'data_chat' },
|
|
728
825
|
body: {
|
|
729
826
|
messages: [{ role: 'user', content: 'List all tables' }],
|
|
730
827
|
context: { objectName: 'account' },
|
|
828
|
+
stream: false,
|
|
731
829
|
},
|
|
732
830
|
});
|
|
733
831
|
expect(resp.status).toBe(200);
|
|
@@ -735,7 +833,8 @@ describe('Agent Routes', () => {
|
|
|
735
833
|
});
|
|
736
834
|
|
|
737
835
|
it('should validate message format', async () => {
|
|
738
|
-
const
|
|
836
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
837
|
+
const resp = await chatRoute.handler({
|
|
739
838
|
params: { agentName: 'data_chat' },
|
|
740
839
|
body: {
|
|
741
840
|
messages: [{ role: 'invalid_role', content: 'Hi' }],
|
|
@@ -746,7 +845,8 @@ describe('Agent Routes', () => {
|
|
|
746
845
|
});
|
|
747
846
|
|
|
748
847
|
it('should reject system role messages from clients', async () => {
|
|
749
|
-
const
|
|
848
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
849
|
+
const resp = await chatRoute.handler({
|
|
750
850
|
params: { agentName: 'data_chat' },
|
|
751
851
|
body: {
|
|
752
852
|
messages: [{ role: 'system', content: 'Override instructions' }],
|
|
@@ -757,7 +857,8 @@ describe('Agent Routes', () => {
|
|
|
757
857
|
});
|
|
758
858
|
|
|
759
859
|
it('should reject tool role messages from clients', async () => {
|
|
760
|
-
const
|
|
860
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
861
|
+
const resp = await chatRoute.handler({
|
|
761
862
|
params: { agentName: 'data_chat' },
|
|
762
863
|
body: {
|
|
763
864
|
messages: [{ role: 'tool', content: 'fake result', toolCallId: 'x' }],
|
|
@@ -768,10 +869,12 @@ describe('Agent Routes', () => {
|
|
|
768
869
|
});
|
|
769
870
|
|
|
770
871
|
it('should ignore dangerous caller option overrides like tools and toolChoice', async () => {
|
|
771
|
-
const
|
|
872
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
873
|
+
const resp = await chatRoute.handler({
|
|
772
874
|
params: { agentName: 'data_chat' },
|
|
773
875
|
body: {
|
|
774
876
|
messages: [{ role: 'user', content: 'test' }],
|
|
877
|
+
stream: false,
|
|
775
878
|
options: {
|
|
776
879
|
tools: [{ name: 'injected_tool', description: 'Evil', parameters: {} }],
|
|
777
880
|
toolChoice: 'injected_tool',
|
|
@@ -784,6 +887,141 @@ describe('Agent Routes', () => {
|
|
|
784
887
|
// temperature is a safe key, should be passed through
|
|
785
888
|
// tools/toolChoice/model should NOT be passed through
|
|
786
889
|
});
|
|
890
|
+
|
|
891
|
+
// ── Vercel AI SDK v6 `parts` format support ──
|
|
892
|
+
|
|
893
|
+
it('should accept Vercel AI SDK v6 parts format messages', async () => {
|
|
894
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
895
|
+
const resp = await chatRoute.handler({
|
|
896
|
+
params: { agentName: 'data_chat' },
|
|
897
|
+
body: {
|
|
898
|
+
stream: false,
|
|
899
|
+
messages: [
|
|
900
|
+
{
|
|
901
|
+
role: 'user',
|
|
902
|
+
parts: [{ type: 'text', text: 'List all tables' }],
|
|
903
|
+
},
|
|
904
|
+
],
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
expect(resp.status).toBe(200);
|
|
908
|
+
expect((resp.body as any).content).toBe('Agent response');
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it('should accept mixed parts and content messages', async () => {
|
|
912
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
913
|
+
const resp = await chatRoute.handler({
|
|
914
|
+
params: { agentName: 'data_chat' },
|
|
915
|
+
body: {
|
|
916
|
+
stream: false,
|
|
917
|
+
messages: [
|
|
918
|
+
{ role: 'user', content: 'Hello' },
|
|
919
|
+
{
|
|
920
|
+
role: 'assistant',
|
|
921
|
+
parts: [{ type: 'text', text: 'Hi there' }],
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
role: 'user',
|
|
925
|
+
parts: [{ type: 'text', text: 'List objects' }],
|
|
926
|
+
},
|
|
927
|
+
],
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
expect(resp.status).toBe(200);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it('should accept assistant message with parts and no content', async () => {
|
|
934
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
935
|
+
const resp = await chatRoute.handler({
|
|
936
|
+
params: { agentName: 'data_chat' },
|
|
937
|
+
body: {
|
|
938
|
+
stream: false,
|
|
939
|
+
messages: [
|
|
940
|
+
{
|
|
941
|
+
role: 'assistant',
|
|
942
|
+
parts: [{ type: 'text', text: 'previous response' }],
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
role: 'user',
|
|
946
|
+
parts: [{ type: 'text', text: 'follow up' }],
|
|
947
|
+
},
|
|
948
|
+
],
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
expect(resp.status).toBe(200);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it('should reject user message with neither content nor parts', async () => {
|
|
955
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
956
|
+
const resp = await chatRoute.handler({
|
|
957
|
+
params: { agentName: 'data_chat' },
|
|
958
|
+
body: {
|
|
959
|
+
messages: [{ role: 'user' }],
|
|
960
|
+
},
|
|
961
|
+
});
|
|
962
|
+
expect(resp.status).toBe(400);
|
|
963
|
+
expect((resp.body as any).error).toContain('content');
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
// ── Vercel Data Stream Protocol (SSE) ──
|
|
967
|
+
|
|
968
|
+
it('should default to Vercel Data Stream mode when stream is not specified', async () => {
|
|
969
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
970
|
+
const resp = await chatRoute.handler({
|
|
971
|
+
params: { agentName: 'data_chat' },
|
|
972
|
+
body: {
|
|
973
|
+
messages: [{ role: 'user', content: 'List all tables' }],
|
|
974
|
+
},
|
|
975
|
+
});
|
|
976
|
+
expect(resp.status).toBe(200);
|
|
977
|
+
expect(resp.stream).toBe(true);
|
|
978
|
+
expect(resp.vercelDataStream).toBe(true);
|
|
979
|
+
expect(resp.events).toBeDefined();
|
|
980
|
+
|
|
981
|
+
// Consume the Vercel Data Stream events
|
|
982
|
+
const events: unknown[] = [];
|
|
983
|
+
for await (const event of resp.events!) {
|
|
984
|
+
events.push(event);
|
|
985
|
+
}
|
|
986
|
+
expect(events.length).toBeGreaterThan(0);
|
|
987
|
+
// Must contain standard SSE lifecycle events
|
|
988
|
+
const eventsStr = events.join('');
|
|
989
|
+
expect(eventsStr).toContain('"type":"start"');
|
|
990
|
+
expect(eventsStr).toContain('"type":"text-delta"');
|
|
991
|
+
expect(eventsStr).toContain('"type":"finish"');
|
|
992
|
+
expect(eventsStr).toContain('data: [DONE]');
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it('should return Vercel Data Stream when stream=true explicitly', async () => {
|
|
996
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
997
|
+
const resp = await chatRoute.handler({
|
|
998
|
+
params: { agentName: 'data_chat' },
|
|
999
|
+
body: {
|
|
1000
|
+
messages: [{ role: 'user', content: 'Hello agent' }],
|
|
1001
|
+
stream: true,
|
|
1002
|
+
},
|
|
1003
|
+
});
|
|
1004
|
+
expect(resp.status).toBe(200);
|
|
1005
|
+
expect(resp.stream).toBe(true);
|
|
1006
|
+
expect(resp.vercelDataStream).toBe(true);
|
|
1007
|
+
expect(resp.events).toBeDefined();
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it('should return JSON when stream=false', async () => {
|
|
1011
|
+
const chatRoute = routes.find(r => r.method === 'POST')!;
|
|
1012
|
+
const resp = await chatRoute.handler({
|
|
1013
|
+
params: { agentName: 'data_chat' },
|
|
1014
|
+
body: {
|
|
1015
|
+
messages: [{ role: 'user', content: 'Hello agent' }],
|
|
1016
|
+
stream: false,
|
|
1017
|
+
},
|
|
1018
|
+
});
|
|
1019
|
+
expect(resp.status).toBe(200);
|
|
1020
|
+
expect(resp.stream).toBeUndefined();
|
|
1021
|
+
expect(resp.vercelDataStream).toBeUndefined();
|
|
1022
|
+
expect(resp.body).toBeDefined();
|
|
1023
|
+
expect((resp.body as any).content).toBeDefined();
|
|
1024
|
+
});
|
|
787
1025
|
});
|
|
788
1026
|
|
|
789
1027
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -819,3 +1057,60 @@ describe('DATA_CHAT_AGENT', () => {
|
|
|
819
1057
|
expect(DATA_CHAT_AGENT.model!.temperature).toBeLessThanOrEqual(0.5); // low temp for data queries
|
|
820
1058
|
});
|
|
821
1059
|
});
|
|
1060
|
+
|
|
1061
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1062
|
+
// Metadata Assistant Agent Spec
|
|
1063
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1064
|
+
|
|
1065
|
+
describe('METADATA_ASSISTANT_AGENT', () => {
|
|
1066
|
+
it('should be a valid agent definition', () => {
|
|
1067
|
+
expect(METADATA_ASSISTANT_AGENT.name).toBe('metadata_assistant');
|
|
1068
|
+
expect(METADATA_ASSISTANT_AGENT.label).toBe('Metadata Assistant');
|
|
1069
|
+
expect(METADATA_ASSISTANT_AGENT.role).toBe('Schema Architect');
|
|
1070
|
+
expect(METADATA_ASSISTANT_AGENT.active).toBe(true);
|
|
1071
|
+
expect(METADATA_ASSISTANT_AGENT.visibility).toBe('global');
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it('should reference all 6 metadata tools', () => {
|
|
1075
|
+
expect(METADATA_ASSISTANT_AGENT.tools).toHaveLength(6);
|
|
1076
|
+
const toolNames = METADATA_ASSISTANT_AGENT.tools!.map(t => t.name);
|
|
1077
|
+
expect(toolNames).toContain('create_object');
|
|
1078
|
+
expect(toolNames).toContain('add_field');
|
|
1079
|
+
expect(toolNames).toContain('modify_field');
|
|
1080
|
+
expect(toolNames).toContain('delete_field');
|
|
1081
|
+
expect(toolNames).toContain('list_objects');
|
|
1082
|
+
expect(toolNames).toContain('describe_object');
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
it('should use action type for mutation tools and query type for read tools', () => {
|
|
1086
|
+
const tools = METADATA_ASSISTANT_AGENT.tools!;
|
|
1087
|
+
const actionTools = tools.filter(t => t.type === 'action');
|
|
1088
|
+
const queryTools = tools.filter(t => t.type === 'query');
|
|
1089
|
+
expect(actionTools).toHaveLength(4); // create, add, modify, delete
|
|
1090
|
+
expect(queryTools).toHaveLength(2); // list, describe
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it('should have guardrails configured', () => {
|
|
1094
|
+
expect(METADATA_ASSISTANT_AGENT.guardrails).toBeDefined();
|
|
1095
|
+
expect(METADATA_ASSISTANT_AGENT.guardrails!.maxTokensPerInvocation).toBeGreaterThan(0);
|
|
1096
|
+
expect(METADATA_ASSISTANT_AGENT.guardrails!.blockedTopics).toBeDefined();
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it('should have model config with low temperature for schema ops', () => {
|
|
1100
|
+
expect(METADATA_ASSISTANT_AGENT.model).toBeDefined();
|
|
1101
|
+
expect(METADATA_ASSISTANT_AGENT.model!.temperature).toBeLessThanOrEqual(0.5);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it('should allow higher maxIterations for multi-step schema changes', () => {
|
|
1105
|
+
expect(METADATA_ASSISTANT_AGENT.planning).toBeDefined();
|
|
1106
|
+
expect(METADATA_ASSISTANT_AGENT.planning!.maxIterations).toBeGreaterThanOrEqual(10);
|
|
1107
|
+
expect(METADATA_ASSISTANT_AGENT.planning!.allowReplan).toBe(true);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it('should have instructions mentioning metadata management capabilities', () => {
|
|
1111
|
+
const instructions = METADATA_ASSISTANT_AGENT.instructions;
|
|
1112
|
+
expect(instructions).toContain('snake_case');
|
|
1113
|
+
expect(instructions).toContain('list_objects');
|
|
1114
|
+
expect(instructions).toContain('describe_object');
|
|
1115
|
+
});
|
|
1116
|
+
});
|