@objectstack/service-ai 4.0.1 → 4.0.2

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.
Files changed (39) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +9 -0
  3. package/dist/index.cjs +1120 -66
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +316 -78
  6. package/dist/index.d.ts +316 -78
  7. package/dist/index.js +1105 -63
  8. package/dist/index.js.map +1 -1
  9. package/package.json +26 -4
  10. package/src/__tests__/ai-service.test.ts +248 -27
  11. package/src/__tests__/auth-and-toolcalling.test.ts +30 -28
  12. package/src/__tests__/chatbot-features.test.ts +229 -82
  13. package/src/__tests__/metadata-tools.test.ts +964 -0
  14. package/src/__tests__/objectql-conversation-service.test.ts +34 -16
  15. package/src/__tests__/vercel-stream-encoder.test.ts +263 -0
  16. package/src/adapters/index.ts +2 -0
  17. package/src/adapters/memory-adapter.ts +17 -9
  18. package/src/adapters/vercel-adapter.ts +148 -0
  19. package/src/agent-runtime.ts +27 -3
  20. package/src/agents/index.ts +1 -0
  21. package/src/agents/metadata-assistant-agent.ts +87 -0
  22. package/src/ai-service.ts +68 -36
  23. package/src/conversation/in-memory-conversation-service.ts +2 -2
  24. package/src/conversation/objectql-conversation-service.ts +67 -18
  25. package/src/index.ts +21 -2
  26. package/src/plugin.ts +166 -9
  27. package/src/routes/agent-routes.ts +26 -3
  28. package/src/routes/ai-routes.ts +156 -13
  29. package/src/stream/index.ts +3 -0
  30. package/src/stream/vercel-stream-encoder.ts +129 -0
  31. package/src/tools/add-field.tool.ts +70 -0
  32. package/src/tools/create-object.tool.ts +66 -0
  33. package/src/tools/delete-field.tool.ts +38 -0
  34. package/src/tools/describe-metadata-object.tool.ts +32 -0
  35. package/src/tools/index.ts +12 -1
  36. package/src/tools/list-metadata-objects.tool.ts +34 -0
  37. package/src/tools/metadata-tools.ts +430 -0
  38. package/src/tools/modify-field.tool.ts +44 -0
  39. 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
- AIMessage,
5
+ ModelMessage,
6
6
  AIResult,
7
7
  AIRequestOptions,
8
- AIToolCall,
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: AIToolCall = {
112
- id: 'call_1',
113
- name: 'get_weather',
114
- arguments: JSON.stringify({ city: 'Tokyo' }),
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 AIMessage[];
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
- expect(secondCallMessages[1].toolCalls).toEqual([toolCall]);
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
- expect(secondCallMessages[2].toolCallId).toBe('call_1');
140
- expect(secondCallMessages[2].content).toContain('"temp":22');
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: [{ id: 'c1', name: 'get_weather', arguments: '{"city":"NYC"}' }] },
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: [{ id: 'c2', name: 'get_time', arguments: '{}' }] },
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
- { id: 'c1', name: 'get_weather', arguments: '{"city":"London"}' },
177
- { id: 'c2', name: 'get_population', arguments: '{"city":"London"}' },
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 AIMessage[];
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: [{ id: 'c', name: 'get_weather', arguments: '{"city":"X"}' }],
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: [{ id: 'c1', name: 'bad_tool', arguments: '{}' }] },
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 AIMessage[];
261
+ const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[];
257
262
  const toolMsg = secondCallMessages.find(m => m.role === 'tool');
258
- expect(toolMsg?.content).toContain('Tool crashed');
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 () => {
@@ -343,12 +355,13 @@ describe('Data Tools', () => {
343
355
  ]);
344
356
 
345
357
  const result = await registry.execute({
346
- id: 'c1',
347
- name: 'list_objects',
348
- arguments: '{}',
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.content);
364
+ const parsed = JSON.parse((result.output as any).value);
352
365
  expect(parsed).toHaveLength(2);
353
366
  expect(parsed[0]).toEqual({ name: 'account', label: 'Account' });
354
367
  });
@@ -364,12 +377,13 @@ describe('Data Tools', () => {
364
377
  });
365
378
 
366
379
  const result = await registry.execute({
367
- id: 'c1',
368
- name: 'describe_object',
369
- arguments: JSON.stringify({ objectName: 'account' }),
380
+ type: 'tool-call' as const,
381
+ toolCallId: 'c1',
382
+ toolName: 'describe_object',
383
+ input: { objectName: 'account' },
370
384
  });
371
385
 
372
- const parsed = JSON.parse(result.content);
386
+ const parsed = JSON.parse((result.output as any).value);
373
387
  expect(parsed.name).toBe('account');
374
388
  expect(parsed.fields.name.type).toBe('text');
375
389
  expect(parsed.fields.name.required).toBe(true);
@@ -378,12 +392,13 @@ describe('Data Tools', () => {
378
392
 
379
393
  it('describe_object should return error for unknown object', async () => {
380
394
  const result = await registry.execute({
381
- id: 'c1',
382
- name: 'describe_object',
383
- arguments: JSON.stringify({ objectName: 'nonexistent' }),
395
+ type: 'tool-call' as const,
396
+ toolCallId: 'c1',
397
+ toolName: 'describe_object',
398
+ input: { objectName: 'nonexistent' },
384
399
  });
385
400
 
386
- const parsed = JSON.parse(result.content);
401
+ const parsed = JSON.parse((result.output as any).value);
387
402
  expect(parsed.error).toContain('not found');
388
403
  });
389
404
 
@@ -392,14 +407,15 @@ describe('Data Tools', () => {
392
407
  (dataEngine.find as any).mockResolvedValue(records);
393
408
 
394
409
  const result = await registry.execute({
395
- id: 'c1',
396
- name: 'query_records',
397
- arguments: JSON.stringify({
410
+ type: 'tool-call' as const,
411
+ toolCallId: 'c1',
412
+ toolName: 'query_records',
413
+ input: {
398
414
  objectName: 'account',
399
415
  where: { status: 'active' },
400
416
  fields: ['name', 'status'],
401
417
  limit: 10,
402
- }),
418
+ },
403
419
  });
404
420
 
405
421
  expect(dataEngine.find).toHaveBeenCalledWith('account', {
@@ -410,7 +426,7 @@ describe('Data Tools', () => {
410
426
  offset: undefined,
411
427
  });
412
428
 
413
- const parsed = JSON.parse(result.content);
429
+ const parsed = JSON.parse((result.output as any).value);
414
430
  expect(parsed.count).toBe(2);
415
431
  expect(parsed.records).toEqual(records);
416
432
  });
@@ -419,9 +435,10 @@ describe('Data Tools', () => {
419
435
  (dataEngine.find as any).mockResolvedValue([]);
420
436
 
421
437
  await registry.execute({
422
- id: 'c1',
423
- name: 'query_records',
424
- arguments: JSON.stringify({ objectName: 'account', limit: 999 }),
438
+ type: 'tool-call' as const,
439
+ toolCallId: 'c1',
440
+ toolName: 'query_records',
441
+ input: { objectName: 'account', limit: 999 },
425
442
  });
426
443
 
427
444
  expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({
@@ -433,9 +450,10 @@ describe('Data Tools', () => {
433
450
  (dataEngine.find as any).mockResolvedValue([]);
434
451
 
435
452
  await registry.execute({
436
- id: 'c1',
437
- name: 'query_records',
438
- arguments: JSON.stringify({ objectName: 'account' }),
453
+ type: 'tool-call' as const,
454
+ toolCallId: 'c1',
455
+ toolName: 'query_records',
456
+ input: { objectName: 'account' },
439
457
  });
440
458
 
441
459
  expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({
@@ -448,9 +466,10 @@ describe('Data Tools', () => {
448
466
  (dataEngine.findOne as any).mockResolvedValue(record);
449
467
 
450
468
  const result = await registry.execute({
451
- id: 'c1',
452
- name: 'get_record',
453
- arguments: JSON.stringify({ objectName: 'account', recordId: 'rec_123' }),
469
+ type: 'tool-call' as const,
470
+ toolCallId: 'c1',
471
+ toolName: 'get_record',
472
+ input: { objectName: 'account', recordId: 'rec_123' },
454
473
  });
455
474
 
456
475
  expect(dataEngine.findOne).toHaveBeenCalledWith('account', {
@@ -458,18 +477,19 @@ describe('Data Tools', () => {
458
477
  fields: undefined,
459
478
  });
460
479
 
461
- const parsed = JSON.parse(result.content);
480
+ const parsed = JSON.parse((result.output as any).value);
462
481
  expect(parsed.name).toBe('Acme Corp');
463
482
  });
464
483
 
465
484
  it('get_record should return error for missing record', async () => {
466
485
  const result = await registry.execute({
467
- id: 'c1',
468
- name: 'get_record',
469
- arguments: JSON.stringify({ objectName: 'account', recordId: 'not_found' }),
486
+ type: 'tool-call' as const,
487
+ toolCallId: 'c1',
488
+ toolName: 'get_record',
489
+ input: { objectName: 'account', recordId: 'not_found' },
470
490
  });
471
491
 
472
- const parsed = JSON.parse(result.content);
492
+ const parsed = JSON.parse((result.output as any).value);
473
493
  expect(parsed.error).toContain('not found');
474
494
  });
475
495
 
@@ -478,13 +498,14 @@ describe('Data Tools', () => {
478
498
  (dataEngine.aggregate as any).mockResolvedValue(aggResult);
479
499
 
480
500
  const result = await registry.execute({
481
- id: 'c1',
482
- name: 'aggregate_data',
483
- arguments: JSON.stringify({
501
+ type: 'tool-call' as const,
502
+ toolCallId: 'c1',
503
+ toolName: 'aggregate_data',
504
+ input: {
484
505
  objectName: 'account',
485
506
  aggregations: [{ function: 'sum', field: 'revenue', alias: 'total_revenue' }],
486
507
  where: { status: 'active' },
487
- }),
508
+ },
488
509
  });
489
510
 
490
511
  expect(dataEngine.aggregate).toHaveBeenCalledWith('account', {
@@ -493,21 +514,22 @@ describe('Data Tools', () => {
493
514
  aggregations: [{ function: 'sum', field: 'revenue', alias: 'total_revenue' }],
494
515
  });
495
516
 
496
- const parsed = JSON.parse(result.content);
517
+ const parsed = JSON.parse((result.output as any).value);
497
518
  expect(parsed).toEqual(aggResult);
498
519
  });
499
520
 
500
521
  it('aggregate_data should reject invalid aggregation functions', async () => {
501
522
  const result = await registry.execute({
502
- id: 'c1',
503
- name: 'aggregate_data',
504
- arguments: JSON.stringify({
523
+ type: 'tool-call' as const,
524
+ toolCallId: 'c1',
525
+ toolName: 'aggregate_data',
526
+ input: {
505
527
  objectName: 'account',
506
528
  aggregations: [{ function: 'drop_table', field: 'id', alias: 'x' }],
507
- }),
529
+ },
508
530
  });
509
531
 
510
- const parsed = JSON.parse(result.content);
532
+ const parsed = JSON.parse((result.output as any).value);
511
533
  expect(parsed.error).toContain('Invalid aggregation function');
512
534
  expect(parsed.error).toContain('drop_table');
513
535
  expect(dataEngine.aggregate).not.toHaveBeenCalled();
@@ -517,9 +539,10 @@ describe('Data Tools', () => {
517
539
  (dataEngine.find as any).mockResolvedValue([]);
518
540
 
519
541
  await registry.execute({
520
- id: 'c1',
521
- name: 'query_records',
522
- arguments: JSON.stringify({ objectName: 'account', limit: -5 }),
542
+ type: 'tool-call' as const,
543
+ toolCallId: 'c1',
544
+ toolName: 'query_records',
545
+ input: { objectName: 'account', limit: -5 },
523
546
  });
524
547
 
525
548
  expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({
@@ -531,9 +554,10 @@ describe('Data Tools', () => {
531
554
  (dataEngine.find as any).mockResolvedValue([]);
532
555
 
533
556
  await registry.execute({
534
- id: 'c1',
535
- name: 'query_records',
536
- arguments: JSON.stringify({ objectName: 'account', limit: 'not_a_number' }),
557
+ type: 'tool-call' as const,
558
+ toolCallId: 'c1',
559
+ toolName: 'query_records',
560
+ input: { objectName: 'account', limit: 'not_a_number' },
537
561
  });
538
562
 
539
563
  expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({
@@ -545,9 +569,10 @@ describe('Data Tools', () => {
545
569
  (dataEngine.find as any).mockResolvedValue([]);
546
570
 
547
571
  await registry.execute({
548
- id: 'c1',
549
- name: 'query_records',
550
- arguments: JSON.stringify({ objectName: 'account', offset: -10 }),
572
+ type: 'tool-call' as const,
573
+ toolCallId: 'c1',
574
+ toolName: 'query_records',
575
+ input: { objectName: 'account', offset: -10 },
551
576
  });
552
577
 
553
578
  expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({
@@ -655,6 +680,45 @@ describe('AgentRuntime', () => {
655
680
  expect(options.model).toBeUndefined();
656
681
  });
657
682
  });
683
+
684
+ describe('listAgents', () => {
685
+ it('should return summaries of all active agents', async () => {
686
+ (metadataService.list as any).mockResolvedValue([
687
+ DATA_CHAT_AGENT,
688
+ METADATA_ASSISTANT_AGENT,
689
+ ]);
690
+ const agents = await runtime.listAgents();
691
+ expect(agents).toHaveLength(2);
692
+ expect(agents[0]).toEqual({ name: 'data_chat', label: 'Data Assistant', role: 'Business Data Analyst' });
693
+ expect(agents[1]).toEqual({ name: 'metadata_assistant', label: 'Metadata Assistant', role: 'Schema Architect' });
694
+ });
695
+
696
+ it('should filter out inactive agents', async () => {
697
+ (metadataService.list as any).mockResolvedValue([
698
+ DATA_CHAT_AGENT,
699
+ { ...METADATA_ASSISTANT_AGENT, active: false },
700
+ ]);
701
+ const agents = await runtime.listAgents();
702
+ expect(agents).toHaveLength(1);
703
+ expect(agents[0].name).toBe('data_chat');
704
+ });
705
+
706
+ it('should return empty array when no agents registered', async () => {
707
+ (metadataService.list as any).mockResolvedValue([]);
708
+ const agents = await runtime.listAgents();
709
+ expect(agents).toEqual([]);
710
+ });
711
+
712
+ it('should skip malformed agent metadata', async () => {
713
+ (metadataService.list as any).mockResolvedValue([
714
+ DATA_CHAT_AGENT,
715
+ { name: 'bad', label: 'Bad' }, // missing required fields
716
+ ]);
717
+ const agents = await runtime.listAgents();
718
+ expect(agents).toHaveLength(1);
719
+ expect(agents[0].name).toBe('data_chat');
720
+ });
721
+ });
658
722
  });
659
723
 
660
724
  // ═══════════════════════════════════════════════════════════════════
@@ -677,19 +741,37 @@ describe('Agent Routes', () => {
677
741
  if (name === 'inactive_agent') return { ...DATA_CHAT_AGENT, name: 'inactive_agent', active: false };
678
742
  return undefined;
679
743
  }),
744
+ list: vi.fn(async () => [DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT]),
680
745
  });
681
746
  runtime = new AgentRuntime(metadataService);
682
747
  routes = buildAgentRoutes(aiService, runtime, silentLogger);
683
748
  });
684
749
 
685
- it('should define one agent chat route', () => {
686
- expect(routes).toHaveLength(1);
687
- expect(routes[0].method).toBe('POST');
688
- expect(routes[0].path).toBe('/api/v1/ai/agents/:agentName/chat');
750
+ it('should define a GET list route and a POST chat route', () => {
751
+ expect(routes).toHaveLength(2);
752
+ expect(routes[0].method).toBe('GET');
753
+ expect(routes[0].path).toBe('/api/v1/ai/agents');
754
+ expect(routes[1].method).toBe('POST');
755
+ expect(routes[1].path).toBe('/api/v1/ai/agents/:agentName/chat');
689
756
  });
690
757
 
758
+ // ── GET /api/v1/ai/agents ──
759
+
760
+ it('should return list of active agents', async () => {
761
+ const listRoute = routes.find(r => r.method === 'GET')!;
762
+ const resp = await listRoute.handler({});
763
+ expect(resp.status).toBe(200);
764
+ const body = resp.body as { agents: Array<{ name: string; label: string; role: string }> };
765
+ expect(body.agents).toHaveLength(2);
766
+ expect(body.agents[0].name).toBe('data_chat');
767
+ expect(body.agents[1].name).toBe('metadata_assistant');
768
+ });
769
+
770
+ // ── POST /api/v1/ai/agents/:agentName/chat ──
771
+
691
772
  it('should return 400 if agentName is missing', async () => {
692
- const resp = await routes[0].handler({
773
+ const chatRoute = routes.find(r => r.method === 'POST')!;
774
+ const resp = await chatRoute.handler({
693
775
  params: {},
694
776
  body: { messages: [{ role: 'user', content: 'Hi' }] },
695
777
  });
@@ -697,7 +779,8 @@ describe('Agent Routes', () => {
697
779
  });
698
780
 
699
781
  it('should return 400 if messages is empty', async () => {
700
- const resp = await routes[0].handler({
782
+ const chatRoute = routes.find(r => r.method === 'POST')!;
783
+ const resp = await chatRoute.handler({
701
784
  params: { agentName: 'data_chat' },
702
785
  body: { messages: [] },
703
786
  });
@@ -705,7 +788,8 @@ describe('Agent Routes', () => {
705
788
  });
706
789
 
707
790
  it('should return 404 for unknown agent', async () => {
708
- const resp = await routes[0].handler({
791
+ const chatRoute = routes.find(r => r.method === 'POST')!;
792
+ const resp = await chatRoute.handler({
709
793
  params: { agentName: 'unknown_agent' },
710
794
  body: { messages: [{ role: 'user', content: 'Hi' }] },
711
795
  });
@@ -714,7 +798,8 @@ describe('Agent Routes', () => {
714
798
  });
715
799
 
716
800
  it('should return 403 for inactive agent', async () => {
717
- const resp = await routes[0].handler({
801
+ const chatRoute = routes.find(r => r.method === 'POST')!;
802
+ const resp = await chatRoute.handler({
718
803
  params: { agentName: 'inactive_agent' },
719
804
  body: { messages: [{ role: 'user', content: 'Hi' }] },
720
805
  });
@@ -723,7 +808,8 @@ describe('Agent Routes', () => {
723
808
  });
724
809
 
725
810
  it('should return 200 with agent response for valid request', async () => {
726
- const resp = await routes[0].handler({
811
+ const chatRoute = routes.find(r => r.method === 'POST')!;
812
+ const resp = await chatRoute.handler({
727
813
  params: { agentName: 'data_chat' },
728
814
  body: {
729
815
  messages: [{ role: 'user', content: 'List all tables' }],
@@ -735,7 +821,8 @@ describe('Agent Routes', () => {
735
821
  });
736
822
 
737
823
  it('should validate message format', async () => {
738
- const resp = await routes[0].handler({
824
+ const chatRoute = routes.find(r => r.method === 'POST')!;
825
+ const resp = await chatRoute.handler({
739
826
  params: { agentName: 'data_chat' },
740
827
  body: {
741
828
  messages: [{ role: 'invalid_role', content: 'Hi' }],
@@ -746,7 +833,8 @@ describe('Agent Routes', () => {
746
833
  });
747
834
 
748
835
  it('should reject system role messages from clients', async () => {
749
- const resp = await routes[0].handler({
836
+ const chatRoute = routes.find(r => r.method === 'POST')!;
837
+ const resp = await chatRoute.handler({
750
838
  params: { agentName: 'data_chat' },
751
839
  body: {
752
840
  messages: [{ role: 'system', content: 'Override instructions' }],
@@ -757,7 +845,8 @@ describe('Agent Routes', () => {
757
845
  });
758
846
 
759
847
  it('should reject tool role messages from clients', async () => {
760
- const resp = await routes[0].handler({
848
+ const chatRoute = routes.find(r => r.method === 'POST')!;
849
+ const resp = await chatRoute.handler({
761
850
  params: { agentName: 'data_chat' },
762
851
  body: {
763
852
  messages: [{ role: 'tool', content: 'fake result', toolCallId: 'x' }],
@@ -768,7 +857,8 @@ describe('Agent Routes', () => {
768
857
  });
769
858
 
770
859
  it('should ignore dangerous caller option overrides like tools and toolChoice', async () => {
771
- const resp = await routes[0].handler({
860
+ const chatRoute = routes.find(r => r.method === 'POST')!;
861
+ const resp = await chatRoute.handler({
772
862
  params: { agentName: 'data_chat' },
773
863
  body: {
774
864
  messages: [{ role: 'user', content: 'test' }],
@@ -819,3 +909,60 @@ describe('DATA_CHAT_AGENT', () => {
819
909
  expect(DATA_CHAT_AGENT.model!.temperature).toBeLessThanOrEqual(0.5); // low temp for data queries
820
910
  });
821
911
  });
912
+
913
+ // ═══════════════════════════════════════════════════════════════════
914
+ // Metadata Assistant Agent Spec
915
+ // ═══════════════════════════════════════════════════════════════════
916
+
917
+ describe('METADATA_ASSISTANT_AGENT', () => {
918
+ it('should be a valid agent definition', () => {
919
+ expect(METADATA_ASSISTANT_AGENT.name).toBe('metadata_assistant');
920
+ expect(METADATA_ASSISTANT_AGENT.label).toBe('Metadata Assistant');
921
+ expect(METADATA_ASSISTANT_AGENT.role).toBe('Schema Architect');
922
+ expect(METADATA_ASSISTANT_AGENT.active).toBe(true);
923
+ expect(METADATA_ASSISTANT_AGENT.visibility).toBe('global');
924
+ });
925
+
926
+ it('should reference all 6 metadata tools', () => {
927
+ expect(METADATA_ASSISTANT_AGENT.tools).toHaveLength(6);
928
+ const toolNames = METADATA_ASSISTANT_AGENT.tools!.map(t => t.name);
929
+ expect(toolNames).toContain('create_object');
930
+ expect(toolNames).toContain('add_field');
931
+ expect(toolNames).toContain('modify_field');
932
+ expect(toolNames).toContain('delete_field');
933
+ expect(toolNames).toContain('list_metadata_objects');
934
+ expect(toolNames).toContain('describe_metadata_object');
935
+ });
936
+
937
+ it('should use action type for mutation tools and query type for read tools', () => {
938
+ const tools = METADATA_ASSISTANT_AGENT.tools!;
939
+ const actionTools = tools.filter(t => t.type === 'action');
940
+ const queryTools = tools.filter(t => t.type === 'query');
941
+ expect(actionTools).toHaveLength(4); // create, add, modify, delete
942
+ expect(queryTools).toHaveLength(2); // list, describe
943
+ });
944
+
945
+ it('should have guardrails configured', () => {
946
+ expect(METADATA_ASSISTANT_AGENT.guardrails).toBeDefined();
947
+ expect(METADATA_ASSISTANT_AGENT.guardrails!.maxTokensPerInvocation).toBeGreaterThan(0);
948
+ expect(METADATA_ASSISTANT_AGENT.guardrails!.blockedTopics).toBeDefined();
949
+ });
950
+
951
+ it('should have model config with low temperature for schema ops', () => {
952
+ expect(METADATA_ASSISTANT_AGENT.model).toBeDefined();
953
+ expect(METADATA_ASSISTANT_AGENT.model!.temperature).toBeLessThanOrEqual(0.5);
954
+ });
955
+
956
+ it('should allow higher maxIterations for multi-step schema changes', () => {
957
+ expect(METADATA_ASSISTANT_AGENT.planning).toBeDefined();
958
+ expect(METADATA_ASSISTANT_AGENT.planning!.maxIterations).toBeGreaterThanOrEqual(10);
959
+ expect(METADATA_ASSISTANT_AGENT.planning!.allowReplan).toBe(true);
960
+ });
961
+
962
+ it('should have instructions mentioning metadata management capabilities', () => {
963
+ const instructions = METADATA_ASSISTANT_AGENT.instructions;
964
+ expect(instructions).toContain('snake_case');
965
+ expect(instructions).toContain('list_metadata_objects');
966
+ expect(instructions).toContain('describe_metadata_object');
967
+ });
968
+ });