@objectstack/service-ai 4.0.2 → 4.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/service-ai",
3
- "version": "4.0.2",
3
+ "version": "4.0.4",
4
4
  "license": "Apache-2.0",
5
5
  "description": "AI Service for ObjectStack — implements IAIService with LLM adapter layer, conversation management, tool registry, and REST/SSE routes",
6
6
  "type": "module",
@@ -15,9 +15,9 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@ai-sdk/provider": "^3.0.8",
18
- "ai": "^6.0.146",
19
- "@objectstack/core": "4.0.2",
20
- "@objectstack/spec": "4.0.2"
18
+ "ai": "^6.0.158",
19
+ "@objectstack/core": "4.0.4",
20
+ "@objectstack/spec": "4.0.4"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "@ai-sdk/anthropic": "^3.0.0",
@@ -40,9 +40,9 @@
40
40
  }
41
41
  },
42
42
  "devDependencies": {
43
- "@types/node": "^25.5.2",
43
+ "@types/node": "^25.6.0",
44
44
  "typescript": "^6.0.2",
45
- "vitest": "^4.1.2"
45
+ "vitest": "^4.1.4"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "tsup --config ../../../tsup.config.ts",
@@ -818,6 +818,23 @@ describe('AIServicePlugin', () => {
818
818
  expect(ctx.replaceService).toHaveBeenCalledWith('ai', expect.any(Object));
819
819
  });
820
820
 
821
+ it('should contribute Setup navigation labels as plain strings', async () => {
822
+ const plugin = new AIServicePlugin();
823
+ const ctx = createMockContext();
824
+ const contribute = vi.fn();
825
+ ctx.registerService('setupNav', { contribute });
826
+
827
+ await plugin.init(ctx);
828
+
829
+ expect(contribute).toHaveBeenCalledWith({
830
+ areaId: 'area_ai',
831
+ items: [
832
+ expect.objectContaining({ id: 'nav_ai_conversations', label: 'Conversations' }),
833
+ expect.objectContaining({ id: 'nav_ai_messages', label: 'Messages' }),
834
+ ],
835
+ });
836
+ });
837
+
821
838
  it('should clean up on destroy', async () => {
822
839
  const plugin = new AIServicePlugin();
823
840
  const ctx = createMockContext();
@@ -868,7 +885,16 @@ describe('AIServicePlugin', () => {
868
885
  });
869
886
 
870
887
  it('should fallback to MemoryLLMAdapter when provider SDK is not installed', async () => {
871
- const plugin = new AIServicePlugin();
888
+ // Mock all provider SDKs to simulate them not being installed.
889
+ // In the workspace @ai-sdk/openai may be resolvable as a transitive
890
+ // dependency, so we must explicitly make the dynamic import fail.
891
+ vi.doMock('@ai-sdk/openai', () => { throw new Error('Cannot find module \'@ai-sdk/openai\''); });
892
+ vi.doMock('@ai-sdk/anthropic', () => { throw new Error('Cannot find module \'@ai-sdk/anthropic\''); });
893
+ vi.doMock('@ai-sdk/google', () => { throw new Error('Cannot find module \'@ai-sdk/google\''); });
894
+
895
+ // Re-import the plugin module so it picks up the mocked imports
896
+ const { AIServicePlugin: FreshPlugin } = await import('../plugin.js');
897
+ const plugin = new FreshPlugin();
872
898
  const ctx = createMockContext();
873
899
 
874
900
  const oldEnv = { ...process.env };
@@ -897,6 +923,9 @@ describe('AIServicePlugin', () => {
897
923
  );
898
924
  } finally {
899
925
  process.env = oldEnv;
926
+ vi.doUnmock('@ai-sdk/openai');
927
+ vi.doUnmock('@ai-sdk/anthropic');
928
+ vi.doUnmock('@ai-sdk/google');
900
929
  }
901
930
  });
902
931
 
@@ -521,16 +521,66 @@ describe('streamChatWithTools', () => {
521
521
  events.push(event);
522
522
  }
523
523
 
524
- // Should have tool-call event followed by text-delta + finish (no double call)
524
+ // Should have tool-call + tool-result events followed by text-delta + finish
525
525
  const toolCallEvents = events.filter(e => e.type === 'tool-call');
526
526
  expect(toolCallEvents).toHaveLength(1);
527
527
  expect((toolCallEvents[0] as any).toolName).toBe('get_weather');
528
528
 
529
+ const toolResultEvents = events.filter(e => e.type === 'tool-result');
530
+ expect(toolResultEvents).toHaveLength(1);
531
+ expect((toolResultEvents[0] as any).toolCallId).toBe('call_1');
532
+ expect((toolResultEvents[0] as any).toolName).toBe('get_weather');
533
+
529
534
  const finishEvent = events.find(e => e.type === 'finish');
530
535
  expect(finishEvent).toBeDefined();
531
536
  expect(adapter.chat).toHaveBeenCalledTimes(2);
532
537
  });
533
538
 
539
+ it('should yield tool-result events with tool output', async () => {
540
+ const toolCall: ToolCallPart = {
541
+ type: 'tool-call',
542
+ toolCallId: 'call_weather',
543
+ toolName: 'get_weather',
544
+ input: { city: 'Paris' },
545
+ };
546
+
547
+ let chatCallIndex = 0;
548
+ const adapter: LLMAdapter = {
549
+ name: 'mock-stream',
550
+ chat: vi.fn(async () => {
551
+ chatCallIndex++;
552
+ if (chatCallIndex === 1) {
553
+ return { content: '', toolCalls: [toolCall] };
554
+ }
555
+ return { content: 'Paris is 22°C' };
556
+ }),
557
+ complete: vi.fn(async () => ({ content: '' })),
558
+ };
559
+
560
+ const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
561
+ const events: TextStreamPart<ToolSet>[] = [];
562
+ for await (const event of service.streamChatWithTools(
563
+ [{ role: 'user', content: 'Weather in Paris?' }],
564
+ )) {
565
+ events.push(event);
566
+ }
567
+
568
+ // Verify the tool-result contains actual tool output
569
+ const toolResultEvents = events.filter(e => e.type === 'tool-result');
570
+ expect(toolResultEvents).toHaveLength(1);
571
+ const toolResult = toolResultEvents[0] as any;
572
+ expect(toolResult.toolCallId).toBe('call_weather');
573
+ expect(toolResult.toolName).toBe('get_weather');
574
+ expect(toolResult.output).toEqual({ type: 'text', value: JSON.stringify({ temp: 22, city: 'Paris' }) });
575
+
576
+ // Verify order: tool-call comes before tool-result
577
+ const toolCallIdx = events.findIndex(e => e.type === 'tool-call');
578
+ const toolResultIdx = events.findIndex(e => e.type === 'tool-result');
579
+ expect(toolCallIdx).toBeGreaterThanOrEqual(0);
580
+ expect(toolResultIdx).toBeGreaterThanOrEqual(0);
581
+ expect(toolCallIdx).toBeLessThan(toolResultIdx);
582
+ });
583
+
534
584
  it('should fall back to non-streaming when adapter has no streamChat', async () => {
535
585
  const adapter: LLMAdapter = {
536
586
  name: 'no-stream',
@@ -304,15 +304,13 @@ describe('AIService.chatWithTools', () => {
304
304
 
305
305
  describe('Data Tools', () => {
306
306
  describe('DATA_TOOL_DEFINITIONS', () => {
307
- it('should define exactly 5 tools', () => {
308
- expect(DATA_TOOL_DEFINITIONS).toHaveLength(5);
307
+ it('should define exactly 3 tools', () => {
308
+ expect(DATA_TOOL_DEFINITIONS).toHaveLength(3);
309
309
  });
310
310
 
311
311
  it('should include all expected tool names', () => {
312
312
  const names = DATA_TOOL_DEFINITIONS.map(t => t.name);
313
313
  expect(names).toEqual([
314
- 'list_objects',
315
- 'describe_object',
316
314
  'query_records',
317
315
  'get_record',
318
316
  'aggregate_data',
@@ -336,22 +334,24 @@ describe('Data Tools', () => {
336
334
  registry = new ToolRegistry();
337
335
  dataEngine = createMockDataEngine();
338
336
  metadataService = createMockMetadataService();
339
- registerDataTools(registry, { dataEngine, metadataService });
337
+ registerDataTools(registry, { dataEngine });
340
338
  });
341
339
 
342
- it('should register all 5 tools', () => {
343
- expect(registry.size).toBe(5);
344
- expect(registry.has('list_objects')).toBe(true);
345
- expect(registry.has('describe_object')).toBe(true);
340
+ it('should register all 3 tools', () => {
341
+ expect(registry.size).toBe(3);
346
342
  expect(registry.has('query_records')).toBe(true);
347
343
  expect(registry.has('get_record')).toBe(true);
348
344
  expect(registry.has('aggregate_data')).toBe(true);
349
345
  });
350
346
 
351
- 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
+
352
352
  (metadataService.listObjects as any).mockResolvedValue([
353
- { name: 'account', label: 'Account' },
354
- { name: 'contact', label: 'Contact' },
353
+ { name: 'account', label: 'Account', fields: { name: { type: 'text' } } },
354
+ { name: 'contact', label: 'Contact', fields: {} },
355
355
  ]);
356
356
 
357
357
  const result = await registry.execute({
@@ -362,11 +362,15 @@ describe('Data Tools', () => {
362
362
  });
363
363
 
364
364
  const parsed = JSON.parse((result.output as any).value);
365
- expect(parsed).toHaveLength(2);
366
- expect(parsed[0]).toEqual({ name: 'account', label: 'Account' });
365
+ expect(parsed.objects).toHaveLength(2);
366
+ expect(parsed.objects[0]).toEqual(expect.objectContaining({ name: 'account', label: 'Account' }));
367
367
  });
368
368
 
369
- 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
+
370
374
  (metadataService.getObject as any).mockResolvedValue({
371
375
  name: 'account',
372
376
  label: 'Account',
@@ -385,12 +389,19 @@ describe('Data Tools', () => {
385
389
 
386
390
  const parsed = JSON.parse((result.output as any).value);
387
391
  expect(parsed.name).toBe('account');
388
- expect(parsed.fields.name.type).toBe('text');
389
- expect(parsed.fields.name.required).toBe(true);
390
- expect(parsed.fields.revenue.type).toBe('number');
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');
391
398
  });
392
399
 
393
- 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
+
394
405
  const result = await registry.execute({
395
406
  type: 'tool-call' as const,
396
407
  toolCallId: 'c1',
@@ -807,13 +818,14 @@ describe('Agent Routes', () => {
807
818
  expect((resp.body as any).error).toContain('not active');
808
819
  });
809
820
 
810
- it('should return 200 with agent response for valid request', async () => {
821
+ it('should return 200 with agent response for valid request (stream=false)', async () => {
811
822
  const chatRoute = routes.find(r => r.method === 'POST')!;
812
823
  const resp = await chatRoute.handler({
813
824
  params: { agentName: 'data_chat' },
814
825
  body: {
815
826
  messages: [{ role: 'user', content: 'List all tables' }],
816
827
  context: { objectName: 'account' },
828
+ stream: false,
817
829
  },
818
830
  });
819
831
  expect(resp.status).toBe(200);
@@ -862,6 +874,7 @@ describe('Agent Routes', () => {
862
874
  params: { agentName: 'data_chat' },
863
875
  body: {
864
876
  messages: [{ role: 'user', content: 'test' }],
877
+ stream: false,
865
878
  options: {
866
879
  tools: [{ name: 'injected_tool', description: 'Evil', parameters: {} }],
867
880
  toolChoice: 'injected_tool',
@@ -874,6 +887,141 @@ describe('Agent Routes', () => {
874
887
  // temperature is a safe key, should be passed through
875
888
  // tools/toolChoice/model should NOT be passed through
876
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
+ });
877
1025
  });
878
1026
 
879
1027
  // ═══════════════════════════════════════════════════════════════════
@@ -930,8 +1078,8 @@ describe('METADATA_ASSISTANT_AGENT', () => {
930
1078
  expect(toolNames).toContain('add_field');
931
1079
  expect(toolNames).toContain('modify_field');
932
1080
  expect(toolNames).toContain('delete_field');
933
- expect(toolNames).toContain('list_metadata_objects');
934
- expect(toolNames).toContain('describe_metadata_object');
1081
+ expect(toolNames).toContain('list_objects');
1082
+ expect(toolNames).toContain('describe_object');
935
1083
  });
936
1084
 
937
1085
  it('should use action type for mutation tools and query type for read tools', () => {
@@ -962,7 +1110,7 @@ describe('METADATA_ASSISTANT_AGENT', () => {
962
1110
  it('should have instructions mentioning metadata management capabilities', () => {
963
1111
  const instructions = METADATA_ASSISTANT_AGENT.instructions;
964
1112
  expect(instructions).toContain('snake_case');
965
- expect(instructions).toContain('list_metadata_objects');
966
- expect(instructions).toContain('describe_metadata_object');
1113
+ expect(instructions).toContain('list_objects');
1114
+ expect(instructions).toContain('describe_object');
967
1115
  });
968
1116
  });
@@ -17,8 +17,8 @@ import { createObjectTool } from '../tools/create-object.tool.js';
17
17
  import { addFieldTool } from '../tools/add-field.tool.js';
18
18
  import { modifyFieldTool } from '../tools/modify-field.tool.js';
19
19
  import { deleteFieldTool } from '../tools/delete-field.tool.js';
20
- import { listMetadataObjectsTool } from '../tools/list-metadata-objects.tool.js';
21
- import { describeMetadataObjectTool } from '../tools/describe-metadata-object.tool.js';
20
+ import { listObjectsTool } from '../tools/list-objects.tool.js';
21
+ import { describeObjectTool } from '../tools/describe-object.tool.js';
22
22
 
23
23
  // ── Helpers ────────────────────────────────────────────────────────
24
24
 
@@ -63,8 +63,8 @@ describe('Metadata Tool Definitions', () => {
63
63
  'add_field',
64
64
  'modify_field',
65
65
  'delete_field',
66
- 'list_metadata_objects',
67
- 'describe_metadata_object',
66
+ 'list_objects',
67
+ 'describe_object',
68
68
  ]);
69
69
  });
70
70
 
@@ -86,8 +86,8 @@ describe('Individual Tool Metadata (.tool.ts)', () => {
86
86
  { tool: addFieldTool, expectedName: 'add_field', expectedLabel: 'Add Field' },
87
87
  { tool: modifyFieldTool, expectedName: 'modify_field', expectedLabel: 'Modify Field' },
88
88
  { tool: deleteFieldTool, expectedName: 'delete_field', expectedLabel: 'Delete Field' },
89
- { tool: listMetadataObjectsTool, expectedName: 'list_metadata_objects', expectedLabel: 'List Metadata Objects' },
90
- { tool: describeMetadataObjectTool, expectedName: 'describe_metadata_object', expectedLabel: 'Describe Metadata Object' },
89
+ { tool: listObjectsTool, expectedName: 'list_objects', expectedLabel: 'List Objects' },
90
+ { tool: describeObjectTool, expectedName: 'describe_object', expectedLabel: 'Describe Object' },
91
91
  ];
92
92
 
93
93
  for (const { tool, expectedName, expectedLabel } of tools) {
@@ -132,8 +132,8 @@ describe('Individual Tool Metadata (.tool.ts)', () => {
132
132
  });
133
133
 
134
134
  it('should not mark read-only tools as requiresConfirmation', () => {
135
- expect(listMetadataObjectsTool.requiresConfirmation).toBe(false);
136
- expect(describeMetadataObjectTool.requiresConfirmation).toBe(false);
135
+ expect(listObjectsTool.requiresConfirmation).toBe(false);
136
+ expect(describeObjectTool.requiresConfirmation).toBe(false);
137
137
  });
138
138
 
139
139
  it('should not mark add_field and modify_field as requiresConfirmation', () => {
@@ -162,8 +162,8 @@ describe('registerMetadataTools', () => {
162
162
  expect(registry.has('add_field')).toBe(true);
163
163
  expect(registry.has('modify_field')).toBe(true);
164
164
  expect(registry.has('delete_field')).toBe(true);
165
- expect(registry.has('list_metadata_objects')).toBe(true);
166
- expect(registry.has('describe_metadata_object')).toBe(true);
165
+ expect(registry.has('list_objects')).toBe(true);
166
+ expect(registry.has('describe_object')).toBe(true);
167
167
  });
168
168
  });
169
169
 
@@ -171,8 +171,8 @@ describe('registerMetadataTools', () => {
171
171
  // Dual registration (data tools + metadata tools)
172
172
  // ═══════════════════════════════════════════════════════════════════
173
173
 
174
- describe('registerDataTools + registerMetadataTools — no collision', () => {
175
- it('should register both tool sets on the same registry without overwriting', () => {
174
+ describe('registerDataTools + registerMetadataTools — unified list/describe', () => {
175
+ it('should register both tool sets on the same registry with shared list_objects and describe_object', () => {
176
176
  const registry = new ToolRegistry();
177
177
  const metadataService = createMockMetadataService();
178
178
  const dataEngine = {
@@ -181,26 +181,32 @@ describe('registerDataTools + registerMetadataTools — no collision', () => {
181
181
  aggregate: vi.fn(),
182
182
  } as any;
183
183
 
184
- registerDataTools(registry, { dataEngine, metadataService });
184
+ registerDataTools(registry, { dataEngine });
185
185
  const sizeAfterData = registry.size;
186
186
 
187
187
  registerMetadataTools(registry, { metadataService });
188
188
  const sizeAfterBoth = registry.size;
189
189
 
190
- // Data tools define: list_objects, describe_object, query_records, get_record, aggregate_data
191
- // Metadata tools define: create_object, add_field, modify_field, delete_field, list_metadata_objects, describe_metadata_object
192
- // No overlap total should be sum of both
190
+ // Data tools define: query_records, get_record, aggregate_data (3)
191
+ // Metadata tools define: create_object, add_field, modify_field, delete_field, list_objects, describe_object (6)
192
+ // Total should be 3 + 6 = 9
193
+ expect(sizeAfterData).toBe(3);
193
194
  expect(sizeAfterBoth).toBe(sizeAfterData + 6);
194
195
 
195
- // Data tools should still be present
196
+ // Unified list/describe should be present (from metadata tools)
196
197
  expect(registry.has('list_objects')).toBe(true);
197
198
  expect(registry.has('describe_object')).toBe(true);
199
+
200
+ // Data-only tools should be present
198
201
  expect(registry.has('query_records')).toBe(true);
202
+ expect(registry.has('get_record')).toBe(true);
203
+ expect(registry.has('aggregate_data')).toBe(true);
199
204
 
200
- // Metadata tools should also be present with distinct names
201
- expect(registry.has('list_metadata_objects')).toBe(true);
202
- expect(registry.has('describe_metadata_object')).toBe(true);
205
+ // Metadata-only tools should be present
203
206
  expect(registry.has('create_object')).toBe(true);
207
+ expect(registry.has('add_field')).toBe(true);
208
+ expect(registry.has('modify_field')).toBe(true);
209
+ expect(registry.has('delete_field')).toBe(true);
204
210
  });
205
211
  });
206
212
 
@@ -752,7 +758,7 @@ describe('list_metadata_objects handler', () => {
752
758
  const result = await registry.execute({
753
759
  type: 'tool-call' as const,
754
760
  toolCallId: 'c1',
755
- toolName: 'list_metadata_objects',
761
+ toolName: 'list_objects',
756
762
  input: {},
757
763
  });
758
764
 
@@ -767,7 +773,7 @@ describe('list_metadata_objects handler', () => {
767
773
  const result = await registry.execute({
768
774
  type: 'tool-call' as const,
769
775
  toolCallId: 'c2',
770
- toolName: 'list_metadata_objects',
776
+ toolName: 'list_objects',
771
777
  input: { filter: 'account' },
772
778
  });
773
779
 
@@ -780,7 +786,7 @@ describe('list_metadata_objects handler', () => {
780
786
  const result = await registry.execute({
781
787
  type: 'tool-call' as const,
782
788
  toolCallId: 'c3',
783
- toolName: 'list_metadata_objects',
789
+ toolName: 'list_objects',
784
790
  input: { includeFields: true },
785
791
  });
786
792
 
@@ -797,7 +803,7 @@ describe('list_metadata_objects handler', () => {
797
803
  const result = await registry.execute({
798
804
  type: 'tool-call' as const,
799
805
  toolCallId: 'c4',
800
- toolName: 'list_metadata_objects',
806
+ toolName: 'list_objects',
801
807
  input: {},
802
808
  });
803
809
 
@@ -836,7 +842,7 @@ describe('describe_metadata_object handler', () => {
836
842
  const result = await registry.execute({
837
843
  type: 'tool-call' as const,
838
844
  toolCallId: 'c1',
839
- toolName: 'describe_metadata_object',
845
+ toolName: 'describe_object',
840
846
  input: { objectName: 'account' },
841
847
  });
842
848
 
@@ -858,7 +864,7 @@ describe('describe_metadata_object handler', () => {
858
864
  const result = await registry.execute({
859
865
  type: 'tool-call' as const,
860
866
  toolCallId: 'c2',
861
- toolName: 'describe_metadata_object',
867
+ toolName: 'describe_object',
862
868
  input: { objectName: 'nonexistent' },
863
869
  });
864
870
 
@@ -909,7 +915,7 @@ describe('Metadata Tools — full lifecycle', () => {
909
915
  const descResult = await registry.execute({
910
916
  type: 'tool-call' as const,
911
917
  toolCallId: 's4',
912
- toolName: 'describe_metadata_object',
918
+ toolName: 'describe_object',
913
919
  input: { objectName: 'invoice' },
914
920
  });
915
921
  const desc = JSON.parse((descResult.output as any).value);
@@ -941,7 +947,7 @@ describe('Metadata Tools — full lifecycle', () => {
941
947
  const descResult2 = await registry.execute({
942
948
  type: 'tool-call' as const,
943
949
  toolCallId: 's7',
944
- toolName: 'describe_metadata_object',
950
+ toolName: 'describe_object',
945
951
  input: { objectName: 'invoice' },
946
952
  });
947
953
  const desc2 = JSON.parse((descResult2.output as any).value);
@@ -954,7 +960,7 @@ describe('Metadata Tools — full lifecycle', () => {
954
960
  const listResult = await registry.execute({
955
961
  type: 'tool-call' as const,
956
962
  toolCallId: 's8',
957
- toolName: 'list_metadata_objects',
963
+ toolName: 'list_objects',
958
964
  input: {},
959
965
  });
960
966
  const list = JSON.parse((listResult.output as any).value);