@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
@@ -0,0 +1,964 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import type { IMetadataService } from '@objectstack/spec/contracts';
5
+ import { ToolRegistry } from '../tools/tool-registry.js';
6
+ import {
7
+ registerMetadataTools,
8
+ METADATA_TOOL_DEFINITIONS,
9
+ } from '../tools/metadata-tools.js';
10
+ import {
11
+ registerDataTools,
12
+ } from '../tools/data-tools.js';
13
+ import type { MetadataToolContext } from '../tools/metadata-tools.js';
14
+
15
+ // Individual tool metadata imports
16
+ import { createObjectTool } from '../tools/create-object.tool.js';
17
+ import { addFieldTool } from '../tools/add-field.tool.js';
18
+ import { modifyFieldTool } from '../tools/modify-field.tool.js';
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';
22
+
23
+ // ── Helpers ────────────────────────────────────────────────────────
24
+
25
+ /** Build a mock IMetadataService with optionally pre-loaded objects. */
26
+ function createMockMetadataService(
27
+ objects: Record<string, any> = {},
28
+ overrides: Partial<IMetadataService> = {},
29
+ ): IMetadataService {
30
+ // Keep a mutable store so handlers can modify it
31
+ const store: Record<string, any> = { ...objects };
32
+
33
+ return {
34
+ register: vi.fn(async (_type: string, name: string, data: unknown) => {
35
+ store[name] = data;
36
+ }),
37
+ get: vi.fn(async (_type: string, name: string) => store[name] ?? undefined),
38
+ list: vi.fn(async () => Object.values(store)),
39
+ unregister: vi.fn(async (_type: string, name: string) => {
40
+ delete store[name];
41
+ }),
42
+ exists: vi.fn(async (_type: string, name: string) => name in store),
43
+ listNames: vi.fn(async () => Object.keys(store)),
44
+ getObject: vi.fn(async (name: string) => store[name] ?? undefined),
45
+ listObjects: vi.fn(async () => Object.values(store)),
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ // ═══════════════════════════════════════════════════════════════════
51
+ // Metadata Tool Definitions
52
+ // ═══════════════════════════════════════════════════════════════════
53
+
54
+ describe('Metadata Tool Definitions', () => {
55
+ it('should define exactly 6 tools', () => {
56
+ expect(METADATA_TOOL_DEFINITIONS).toHaveLength(6);
57
+ });
58
+
59
+ it('should include all expected tool names', () => {
60
+ const names = METADATA_TOOL_DEFINITIONS.map(t => t.name);
61
+ expect(names).toEqual([
62
+ 'create_object',
63
+ 'add_field',
64
+ 'modify_field',
65
+ 'delete_field',
66
+ 'list_metadata_objects',
67
+ 'describe_metadata_object',
68
+ ]);
69
+ });
70
+
71
+ it('should have descriptions and parameters for each tool', () => {
72
+ for (const def of METADATA_TOOL_DEFINITIONS) {
73
+ expect(def.description).toBeTruthy();
74
+ expect(def.parameters).toBeDefined();
75
+ }
76
+ });
77
+ });
78
+
79
+ // ═══════════════════════════════════════════════════════════════════
80
+ // Individual Tool Metadata Files (.tool.ts)
81
+ // ═══════════════════════════════════════════════════════════════════
82
+
83
+ describe('Individual Tool Metadata (.tool.ts)', () => {
84
+ const tools = [
85
+ { tool: createObjectTool, expectedName: 'create_object', expectedLabel: 'Create Object' },
86
+ { tool: addFieldTool, expectedName: 'add_field', expectedLabel: 'Add Field' },
87
+ { tool: modifyFieldTool, expectedName: 'modify_field', expectedLabel: 'Modify Field' },
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' },
91
+ ];
92
+
93
+ for (const { tool, expectedName, expectedLabel } of tools) {
94
+ describe(expectedName, () => {
95
+ it('should have correct name', () => {
96
+ expect(tool.name).toBe(expectedName);
97
+ });
98
+
99
+ it('should have a label', () => {
100
+ expect(tool.label).toBe(expectedLabel);
101
+ });
102
+
103
+ it('should be categorized as data', () => {
104
+ expect(tool.category).toBe('data');
105
+ });
106
+
107
+ it('should be marked as built-in', () => {
108
+ expect(tool.builtIn).toBe(true);
109
+ });
110
+
111
+ it('should have a description', () => {
112
+ expect(tool.description).toBeTruthy();
113
+ });
114
+
115
+ it('should have parameters schema', () => {
116
+ expect(tool.parameters).toBeDefined();
117
+ expect(tool.parameters.type).toBe('object');
118
+ });
119
+
120
+ it('should be included in METADATA_TOOL_DEFINITIONS', () => {
121
+ expect(METADATA_TOOL_DEFINITIONS).toContain(tool);
122
+ });
123
+ });
124
+ }
125
+
126
+ it('should not set requiresConfirmation on create_object (server-side enforcement not yet implemented)', () => {
127
+ expect(createObjectTool.requiresConfirmation).toBe(false);
128
+ });
129
+
130
+ it('should not set requiresConfirmation on delete_field (server-side enforcement not yet implemented)', () => {
131
+ expect(deleteFieldTool.requiresConfirmation).toBe(false);
132
+ });
133
+
134
+ it('should not mark read-only tools as requiresConfirmation', () => {
135
+ expect(listMetadataObjectsTool.requiresConfirmation).toBe(false);
136
+ expect(describeMetadataObjectTool.requiresConfirmation).toBe(false);
137
+ });
138
+
139
+ it('should not mark add_field and modify_field as requiresConfirmation', () => {
140
+ expect(addFieldTool.requiresConfirmation).toBe(false);
141
+ expect(modifyFieldTool.requiresConfirmation).toBe(false);
142
+ });
143
+ });
144
+
145
+ // ═══════════════════════════════════════════════════════════════════
146
+ // registerMetadataTools + Handlers
147
+ // ═══════════════════════════════════════════════════════════════════
148
+
149
+ describe('registerMetadataTools', () => {
150
+ let registry: ToolRegistry;
151
+ let metadataService: IMetadataService;
152
+
153
+ beforeEach(() => {
154
+ registry = new ToolRegistry();
155
+ metadataService = createMockMetadataService();
156
+ registerMetadataTools(registry, { metadataService });
157
+ });
158
+
159
+ it('should register all 6 tools', () => {
160
+ expect(registry.size).toBe(6);
161
+ expect(registry.has('create_object')).toBe(true);
162
+ expect(registry.has('add_field')).toBe(true);
163
+ expect(registry.has('modify_field')).toBe(true);
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);
167
+ });
168
+ });
169
+
170
+ // ═══════════════════════════════════════════════════════════════════
171
+ // Dual registration (data tools + metadata tools)
172
+ // ═══════════════════════════════════════════════════════════════════
173
+
174
+ describe('registerDataTools + registerMetadataTools — no collision', () => {
175
+ it('should register both tool sets on the same registry without overwriting', () => {
176
+ const registry = new ToolRegistry();
177
+ const metadataService = createMockMetadataService();
178
+ const dataEngine = {
179
+ find: vi.fn(),
180
+ findOne: vi.fn(),
181
+ aggregate: vi.fn(),
182
+ } as any;
183
+
184
+ registerDataTools(registry, { dataEngine, metadataService });
185
+ const sizeAfterData = registry.size;
186
+
187
+ registerMetadataTools(registry, { metadataService });
188
+ const sizeAfterBoth = registry.size;
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
193
+ expect(sizeAfterBoth).toBe(sizeAfterData + 6);
194
+
195
+ // Data tools should still be present
196
+ expect(registry.has('list_objects')).toBe(true);
197
+ expect(registry.has('describe_object')).toBe(true);
198
+ expect(registry.has('query_records')).toBe(true);
199
+
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);
203
+ expect(registry.has('create_object')).toBe(true);
204
+ });
205
+ });
206
+
207
+ // ═══════════════════════════════════════════════════════════════════
208
+ // create_object handler
209
+ // ═══════════════════════════════════════════════════════════════════
210
+
211
+ describe('create_object handler', () => {
212
+ let registry: ToolRegistry;
213
+ let metadataService: IMetadataService;
214
+
215
+ beforeEach(() => {
216
+ registry = new ToolRegistry();
217
+ metadataService = createMockMetadataService();
218
+ registerMetadataTools(registry, { metadataService });
219
+ });
220
+
221
+ it('should create object with name and label', async () => {
222
+ const result = await registry.execute({
223
+ type: 'tool-call' as const,
224
+ toolCallId: 'c1',
225
+ toolName: 'create_object',
226
+ input: { name: 'project', label: 'Project' },
227
+ });
228
+
229
+ const parsed = JSON.parse((result.output as any).value);
230
+ expect(parsed.name).toBe('project');
231
+ expect(parsed.label).toBe('Project');
232
+ expect(parsed.fieldCount).toBe(0);
233
+ expect(metadataService.register).toHaveBeenCalledWith(
234
+ 'object',
235
+ 'project',
236
+ expect.objectContaining({ name: 'project', label: 'Project' }),
237
+ );
238
+ });
239
+
240
+ it('should create object with initial fields', async () => {
241
+ const result = await registry.execute({
242
+ type: 'tool-call' as const,
243
+ toolCallId: 'c2',
244
+ toolName: 'create_object',
245
+ input: {
246
+ name: 'task',
247
+ label: 'Task',
248
+ fields: [
249
+ { name: 'title', type: 'text', label: 'Title', required: true },
250
+ { name: 'status', type: 'select' },
251
+ ],
252
+ },
253
+ });
254
+
255
+ const parsed = JSON.parse((result.output as any).value);
256
+ expect(parsed.name).toBe('task');
257
+ expect(parsed.fieldCount).toBe(2);
258
+ expect(metadataService.register).toHaveBeenCalledWith(
259
+ 'object',
260
+ 'task',
261
+ expect.objectContaining({
262
+ fields: {
263
+ title: { type: 'text', label: 'Title', required: true },
264
+ status: { type: 'select' },
265
+ },
266
+ }),
267
+ );
268
+ });
269
+
270
+ it('should create object with enableFeatures', async () => {
271
+ await registry.execute({
272
+ type: 'tool-call' as const,
273
+ toolCallId: 'c3',
274
+ toolName: 'create_object',
275
+ input: {
276
+ name: 'account',
277
+ label: 'Account',
278
+ enableFeatures: { trackHistory: true, apiEnabled: true },
279
+ },
280
+ });
281
+
282
+ expect(metadataService.register).toHaveBeenCalledWith(
283
+ 'object',
284
+ 'account',
285
+ expect.objectContaining({
286
+ enable: { trackHistory: true, apiEnabled: true },
287
+ }),
288
+ );
289
+ });
290
+
291
+ it('should reject invalid snake_case name', async () => {
292
+ const result = await registry.execute({
293
+ type: 'tool-call' as const,
294
+ toolCallId: 'c4',
295
+ toolName: 'create_object',
296
+ input: { name: 'MyProject', label: 'My Project' },
297
+ });
298
+
299
+ const parsed = JSON.parse((result.output as any).value);
300
+ expect(parsed.error).toContain('snake_case');
301
+ expect(metadataService.register).not.toHaveBeenCalled();
302
+ });
303
+
304
+ it('should reject duplicate object names', async () => {
305
+ // Pre-populate the store
306
+ metadataService = createMockMetadataService({
307
+ project: { name: 'project', label: 'Project' },
308
+ });
309
+ registry = new ToolRegistry();
310
+ registerMetadataTools(registry, { metadataService });
311
+
312
+ const result = await registry.execute({
313
+ type: 'tool-call' as const,
314
+ toolCallId: 'c5',
315
+ toolName: 'create_object',
316
+ input: { name: 'project', label: 'Project v2' },
317
+ });
318
+
319
+ const parsed = JSON.parse((result.output as any).value);
320
+ expect(parsed.error).toContain('already exists');
321
+ });
322
+
323
+ it('should return error when name or label is missing', async () => {
324
+ const result = await registry.execute({
325
+ type: 'tool-call' as const,
326
+ toolCallId: 'c6',
327
+ toolName: 'create_object',
328
+ input: { name: 'project' },
329
+ });
330
+
331
+ const parsed = JSON.parse((result.output as any).value);
332
+ expect(parsed.error).toContain('required');
333
+ });
334
+
335
+ it('should reject fields with invalid snake_case names', async () => {
336
+ const result = await registry.execute({
337
+ type: 'tool-call' as const,
338
+ toolCallId: 'c7',
339
+ toolName: 'create_object',
340
+ input: {
341
+ name: 'project',
342
+ label: 'Project',
343
+ fields: [
344
+ { name: 'ValidField', type: 'text' },
345
+ ],
346
+ },
347
+ });
348
+
349
+ const parsed = JSON.parse((result.output as any).value);
350
+ expect(parsed.error).toContain('snake_case');
351
+ expect(metadataService.register).not.toHaveBeenCalled();
352
+ });
353
+
354
+ it('should reject fields with duplicate names', async () => {
355
+ const result = await registry.execute({
356
+ type: 'tool-call' as const,
357
+ toolCallId: 'c8',
358
+ toolName: 'create_object',
359
+ input: {
360
+ name: 'project',
361
+ label: 'Project',
362
+ fields: [
363
+ { name: 'status', type: 'text' },
364
+ { name: 'status', type: 'select' },
365
+ ],
366
+ },
367
+ });
368
+
369
+ const parsed = JSON.parse((result.output as any).value);
370
+ expect(parsed.error).toContain('Duplicate');
371
+ expect(metadataService.register).not.toHaveBeenCalled();
372
+ });
373
+
374
+ it('should reject fields with missing name', async () => {
375
+ const result = await registry.execute({
376
+ type: 'tool-call' as const,
377
+ toolCallId: 'c9',
378
+ toolName: 'create_object',
379
+ input: {
380
+ name: 'project',
381
+ label: 'Project',
382
+ fields: [
383
+ { type: 'text' },
384
+ ],
385
+ },
386
+ });
387
+
388
+ const parsed = JSON.parse((result.output as any).value);
389
+ expect(parsed.error).toBeTruthy();
390
+ expect(metadataService.register).not.toHaveBeenCalled();
391
+ });
392
+ });
393
+
394
+ // ═══════════════════════════════════════════════════════════════════
395
+ // add_field handler
396
+ // ═══════════════════════════════════════════════════════════════════
397
+
398
+ describe('add_field handler', () => {
399
+ let registry: ToolRegistry;
400
+ let metadataService: IMetadataService;
401
+
402
+ beforeEach(() => {
403
+ metadataService = createMockMetadataService({
404
+ project: { name: 'project', label: 'Project', fields: {} },
405
+ });
406
+ registry = new ToolRegistry();
407
+ registerMetadataTools(registry, { metadataService });
408
+ });
409
+
410
+ it('should add a field to an existing object', async () => {
411
+ const result = await registry.execute({
412
+ type: 'tool-call' as const,
413
+ toolCallId: 'c1',
414
+ toolName: 'add_field',
415
+ input: { objectName: 'project', name: 'due_date', type: 'date', label: 'Due Date' },
416
+ });
417
+
418
+ const parsed = JSON.parse((result.output as any).value);
419
+ expect(parsed.objectName).toBe('project');
420
+ expect(parsed.fieldName).toBe('due_date');
421
+ expect(parsed.fieldType).toBe('date');
422
+ expect(metadataService.register).toHaveBeenCalledWith(
423
+ 'object',
424
+ 'project',
425
+ expect.objectContaining({
426
+ fields: expect.objectContaining({
427
+ due_date: expect.objectContaining({ type: 'date', label: 'Due Date' }),
428
+ }),
429
+ }),
430
+ );
431
+ });
432
+
433
+ it('should add a field with options (select type)', async () => {
434
+ await registry.execute({
435
+ type: 'tool-call' as const,
436
+ toolCallId: 'c2',
437
+ toolName: 'add_field',
438
+ input: {
439
+ objectName: 'project',
440
+ name: 'priority',
441
+ type: 'select',
442
+ options: [
443
+ { label: 'Low', value: 'low' },
444
+ { label: 'High', value: 'high' },
445
+ ],
446
+ },
447
+ });
448
+
449
+ expect(metadataService.register).toHaveBeenCalledWith(
450
+ 'object',
451
+ 'project',
452
+ expect.objectContaining({
453
+ fields: expect.objectContaining({
454
+ priority: expect.objectContaining({
455
+ type: 'select',
456
+ options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }],
457
+ }),
458
+ }),
459
+ }),
460
+ );
461
+ });
462
+
463
+ it('should reject adding field to non-existent object', async () => {
464
+ const result = await registry.execute({
465
+ type: 'tool-call' as const,
466
+ toolCallId: 'c3',
467
+ toolName: 'add_field',
468
+ input: { objectName: 'nonexistent', name: 'field_a', type: 'text' },
469
+ });
470
+
471
+ const parsed = JSON.parse((result.output as any).value);
472
+ expect(parsed.error).toContain('not found');
473
+ });
474
+
475
+ it('should reject duplicate field name', async () => {
476
+ // Add the field first
477
+ await registry.execute({
478
+ type: 'tool-call' as const,
479
+ toolCallId: 'c4a',
480
+ toolName: 'add_field',
481
+ input: { objectName: 'project', name: 'status', type: 'text' },
482
+ });
483
+
484
+ // Try to add the same field again
485
+ const result = await registry.execute({
486
+ type: 'tool-call' as const,
487
+ toolCallId: 'c4b',
488
+ toolName: 'add_field',
489
+ input: { objectName: 'project', name: 'status', type: 'select' },
490
+ });
491
+
492
+ const parsed = JSON.parse((result.output as any).value);
493
+ expect(parsed.error).toContain('already exists');
494
+ });
495
+
496
+ it('should reject invalid field name', async () => {
497
+ const result = await registry.execute({
498
+ type: 'tool-call' as const,
499
+ toolCallId: 'c5',
500
+ toolName: 'add_field',
501
+ input: { objectName: 'project', name: 'MyField', type: 'text' },
502
+ });
503
+
504
+ const parsed = JSON.parse((result.output as any).value);
505
+ expect(parsed.error).toContain('snake_case');
506
+ });
507
+
508
+ it('should reject invalid objectName (not snake_case)', async () => {
509
+ const result = await registry.execute({
510
+ type: 'tool-call' as const,
511
+ toolCallId: 'c6',
512
+ toolName: 'add_field',
513
+ input: { objectName: 'MyProject', name: 'status', type: 'text' },
514
+ });
515
+
516
+ const parsed = JSON.parse((result.output as any).value);
517
+ expect(parsed.error).toContain('snake_case');
518
+ });
519
+
520
+ it('should accept reference as a string (not object)', async () => {
521
+ const result = await registry.execute({
522
+ type: 'tool-call' as const,
523
+ toolCallId: 'c7',
524
+ toolName: 'add_field',
525
+ input: { objectName: 'project', name: 'account_id', type: 'lookup', reference: 'account' },
526
+ });
527
+
528
+ const parsed = JSON.parse((result.output as any).value);
529
+ expect(parsed.fieldName).toBe('account_id');
530
+ expect(metadataService.register).toHaveBeenCalledWith(
531
+ 'object',
532
+ 'project',
533
+ expect.objectContaining({
534
+ fields: expect.objectContaining({
535
+ account_id: expect.objectContaining({ type: 'lookup', reference: 'account' }),
536
+ }),
537
+ }),
538
+ );
539
+ });
540
+
541
+ it('should reject invalid reference (not snake_case)', async () => {
542
+ const result = await registry.execute({
543
+ type: 'tool-call' as const,
544
+ toolCallId: 'c8',
545
+ toolName: 'add_field',
546
+ input: { objectName: 'project', name: 'account_id', type: 'lookup', reference: 'MyAccount' },
547
+ });
548
+
549
+ const parsed = JSON.parse((result.output as any).value);
550
+ expect(parsed.error).toContain('snake_case');
551
+ });
552
+
553
+ it('should reject invalid select option values (not snake_case)', async () => {
554
+ const result = await registry.execute({
555
+ type: 'tool-call' as const,
556
+ toolCallId: 'c9',
557
+ toolName: 'add_field',
558
+ input: {
559
+ objectName: 'project',
560
+ name: 'priority',
561
+ type: 'select',
562
+ options: [
563
+ { label: 'High Priority', value: 'HighPriority' },
564
+ ],
565
+ },
566
+ });
567
+
568
+ const parsed = JSON.parse((result.output as any).value);
569
+ expect(parsed.error).toContain('snake_case');
570
+ });
571
+ });
572
+
573
+ // ═══════════════════════════════════════════════════════════════════
574
+ // modify_field handler
575
+ // ═══════════════════════════════════════════════════════════════════
576
+
577
+ describe('modify_field handler', () => {
578
+ let registry: ToolRegistry;
579
+ let metadataService: IMetadataService;
580
+
581
+ beforeEach(() => {
582
+ metadataService = createMockMetadataService({
583
+ project: {
584
+ name: 'project',
585
+ label: 'Project',
586
+ fields: {
587
+ status: { type: 'text', label: 'Status', required: false },
588
+ budget: { type: 'number', label: 'Budget' },
589
+ },
590
+ },
591
+ });
592
+ registry = new ToolRegistry();
593
+ registerMetadataTools(registry, { metadataService });
594
+ });
595
+
596
+ it('should modify field label', async () => {
597
+ const result = await registry.execute({
598
+ type: 'tool-call' as const,
599
+ toolCallId: 'c1',
600
+ toolName: 'modify_field',
601
+ input: {
602
+ objectName: 'project',
603
+ fieldName: 'status',
604
+ changes: { label: 'Project Status' },
605
+ },
606
+ });
607
+
608
+ const parsed = JSON.parse((result.output as any).value);
609
+ expect(parsed.objectName).toBe('project');
610
+ expect(parsed.fieldName).toBe('status');
611
+ expect(parsed.updatedProperties).toEqual(['label']);
612
+ });
613
+
614
+ it('should modify multiple field properties', async () => {
615
+ const result = await registry.execute({
616
+ type: 'tool-call' as const,
617
+ toolCallId: 'c2',
618
+ toolName: 'modify_field',
619
+ input: {
620
+ objectName: 'project',
621
+ fieldName: 'status',
622
+ changes: { label: 'Project Status', required: true },
623
+ },
624
+ });
625
+
626
+ const parsed = JSON.parse((result.output as any).value);
627
+ expect(parsed.updatedProperties).toEqual(expect.arrayContaining(['label', 'required']));
628
+ });
629
+
630
+ it('should return error for non-existent object', async () => {
631
+ const result = await registry.execute({
632
+ type: 'tool-call' as const,
633
+ toolCallId: 'c3',
634
+ toolName: 'modify_field',
635
+ input: {
636
+ objectName: 'nonexistent',
637
+ fieldName: 'status',
638
+ changes: { label: 'New' },
639
+ },
640
+ });
641
+
642
+ const parsed = JSON.parse((result.output as any).value);
643
+ expect(parsed.error).toContain('not found');
644
+ });
645
+
646
+ it('should return error for non-existent field', async () => {
647
+ const result = await registry.execute({
648
+ type: 'tool-call' as const,
649
+ toolCallId: 'c4',
650
+ toolName: 'modify_field',
651
+ input: {
652
+ objectName: 'project',
653
+ fieldName: 'nonexistent_field',
654
+ changes: { label: 'New' },
655
+ },
656
+ });
657
+
658
+ const parsed = JSON.parse((result.output as any).value);
659
+ expect(parsed.error).toContain('not found');
660
+ });
661
+ });
662
+
663
+ // ═══════════════════════════════════════════════════════════════════
664
+ // delete_field handler
665
+ // ═══════════════════════════════════════════════════════════════════
666
+
667
+ describe('delete_field handler', () => {
668
+ let registry: ToolRegistry;
669
+ let metadataService: IMetadataService;
670
+
671
+ beforeEach(() => {
672
+ metadataService = createMockMetadataService({
673
+ project: {
674
+ name: 'project',
675
+ label: 'Project',
676
+ fields: {
677
+ status: { type: 'text', label: 'Status' },
678
+ budget: { type: 'number', label: 'Budget' },
679
+ },
680
+ },
681
+ });
682
+ registry = new ToolRegistry();
683
+ registerMetadataTools(registry, { metadataService });
684
+ });
685
+
686
+ it('should delete a field from an object', async () => {
687
+ const result = await registry.execute({
688
+ type: 'tool-call' as const,
689
+ toolCallId: 'c1',
690
+ toolName: 'delete_field',
691
+ input: { objectName: 'project', fieldName: 'budget' },
692
+ });
693
+
694
+ const parsed = JSON.parse((result.output as any).value);
695
+ expect(parsed.objectName).toBe('project');
696
+ expect(parsed.fieldName).toBe('budget');
697
+ expect(parsed.success).toBe(true);
698
+
699
+ // Verify the field was removed from the re-registered object
700
+ expect(metadataService.register).toHaveBeenCalledWith(
701
+ 'object',
702
+ 'project',
703
+ expect.objectContaining({
704
+ fields: expect.not.objectContaining({ budget: expect.anything() }),
705
+ }),
706
+ );
707
+ });
708
+
709
+ it('should return error for non-existent object', async () => {
710
+ const result = await registry.execute({
711
+ type: 'tool-call' as const,
712
+ toolCallId: 'c2',
713
+ toolName: 'delete_field',
714
+ input: { objectName: 'nonexistent', fieldName: 'status' },
715
+ });
716
+
717
+ const parsed = JSON.parse((result.output as any).value);
718
+ expect(parsed.error).toContain('not found');
719
+ });
720
+
721
+ it('should return error for non-existent field', async () => {
722
+ const result = await registry.execute({
723
+ type: 'tool-call' as const,
724
+ toolCallId: 'c3',
725
+ toolName: 'delete_field',
726
+ input: { objectName: 'project', fieldName: 'nonexistent_field' },
727
+ });
728
+
729
+ const parsed = JSON.parse((result.output as any).value);
730
+ expect(parsed.error).toContain('not found');
731
+ });
732
+ });
733
+
734
+ // ═══════════════════════════════════════════════════════════════════
735
+ // list_metadata_objects handler
736
+ // ═══════════════════════════════════════════════════════════════════
737
+
738
+ describe('list_metadata_objects handler', () => {
739
+ let registry: ToolRegistry;
740
+ let metadataService: IMetadataService;
741
+
742
+ beforeEach(() => {
743
+ metadataService = createMockMetadataService({
744
+ account: { name: 'account', label: 'Account', fields: { name: { type: 'text' } } },
745
+ contact: { name: 'contact', label: 'Contact', fields: { email: { type: 'text' }, phone: { type: 'text' } } },
746
+ });
747
+ registry = new ToolRegistry();
748
+ registerMetadataTools(registry, { metadataService });
749
+ });
750
+
751
+ it('should list all objects with name, label, and field count', async () => {
752
+ const result = await registry.execute({
753
+ type: 'tool-call' as const,
754
+ toolCallId: 'c1',
755
+ toolName: 'list_metadata_objects',
756
+ input: {},
757
+ });
758
+
759
+ const parsed = JSON.parse((result.output as any).value);
760
+ expect(parsed.totalCount).toBe(2);
761
+ expect(parsed.objects).toHaveLength(2);
762
+ expect(parsed.objects[0]).toEqual(expect.objectContaining({ name: 'account', label: 'Account', fieldCount: 1 }));
763
+ expect(parsed.objects[1]).toEqual(expect.objectContaining({ name: 'contact', label: 'Contact', fieldCount: 2 }));
764
+ });
765
+
766
+ it('should filter objects by name/label substring', async () => {
767
+ const result = await registry.execute({
768
+ type: 'tool-call' as const,
769
+ toolCallId: 'c2',
770
+ toolName: 'list_metadata_objects',
771
+ input: { filter: 'account' },
772
+ });
773
+
774
+ const parsed = JSON.parse((result.output as any).value);
775
+ expect(parsed.totalCount).toBe(1);
776
+ expect(parsed.objects[0].name).toBe('account');
777
+ });
778
+
779
+ it('should include field summaries when includeFields is true', async () => {
780
+ const result = await registry.execute({
781
+ type: 'tool-call' as const,
782
+ toolCallId: 'c3',
783
+ toolName: 'list_metadata_objects',
784
+ input: { includeFields: true },
785
+ });
786
+
787
+ const parsed = JSON.parse((result.output as any).value);
788
+ expect(parsed.objects[0].fields).toBeDefined();
789
+ expect(parsed.objects[0].fields).toHaveLength(1);
790
+ });
791
+
792
+ it('should return empty list when no objects exist', async () => {
793
+ metadataService = createMockMetadataService({});
794
+ registry = new ToolRegistry();
795
+ registerMetadataTools(registry, { metadataService });
796
+
797
+ const result = await registry.execute({
798
+ type: 'tool-call' as const,
799
+ toolCallId: 'c4',
800
+ toolName: 'list_metadata_objects',
801
+ input: {},
802
+ });
803
+
804
+ const parsed = JSON.parse((result.output as any).value);
805
+ expect(parsed.totalCount).toBe(0);
806
+ expect(parsed.objects).toHaveLength(0);
807
+ });
808
+ });
809
+
810
+ // ═══════════════════════════════════════════════════════════════════
811
+ // describe_metadata_object handler
812
+ // ═══════════════════════════════════════════════════════════════════
813
+
814
+ describe('describe_metadata_object handler', () => {
815
+ let registry: ToolRegistry;
816
+ let metadataService: IMetadataService;
817
+
818
+ beforeEach(() => {
819
+ metadataService = createMockMetadataService({
820
+ account: {
821
+ name: 'account',
822
+ label: 'Account',
823
+ fields: {
824
+ name: { type: 'text', label: 'Account Name', required: true },
825
+ revenue: { type: 'number', label: 'Revenue' },
826
+ industry: { type: 'select', label: 'Industry', options: ['Tech', 'Finance'] },
827
+ },
828
+ enable: { trackHistory: true, apiEnabled: true },
829
+ },
830
+ });
831
+ registry = new ToolRegistry();
832
+ registerMetadataTools(registry, { metadataService });
833
+ });
834
+
835
+ it('should return full schema details with field array', async () => {
836
+ const result = await registry.execute({
837
+ type: 'tool-call' as const,
838
+ toolCallId: 'c1',
839
+ toolName: 'describe_metadata_object',
840
+ input: { objectName: 'account' },
841
+ });
842
+
843
+ const parsed = JSON.parse((result.output as any).value);
844
+ expect(parsed.name).toBe('account');
845
+ expect(parsed.label).toBe('Account');
846
+ expect(parsed.fields).toHaveLength(3);
847
+ expect(parsed.enableFeatures).toEqual({ trackHistory: true, apiEnabled: true });
848
+
849
+ const nameField = parsed.fields.find((f: any) => f.name === 'name');
850
+ expect(nameField.type).toBe('text');
851
+ expect(nameField.required).toBe(true);
852
+
853
+ const industryField = parsed.fields.find((f: any) => f.name === 'industry');
854
+ expect(industryField.options).toEqual(['Tech', 'Finance']);
855
+ });
856
+
857
+ it('should return error for unknown object', async () => {
858
+ const result = await registry.execute({
859
+ type: 'tool-call' as const,
860
+ toolCallId: 'c2',
861
+ toolName: 'describe_metadata_object',
862
+ input: { objectName: 'nonexistent' },
863
+ });
864
+
865
+ const parsed = JSON.parse((result.output as any).value);
866
+ expect(parsed.error).toContain('not found');
867
+ });
868
+ });
869
+
870
+ // ═══════════════════════════════════════════════════════════════════
871
+ // End-to-End: full lifecycle
872
+ // ═══════════════════════════════════════════════════════════════════
873
+
874
+ describe('Metadata Tools — full lifecycle', () => {
875
+ let registry: ToolRegistry;
876
+ let metadataService: IMetadataService;
877
+
878
+ beforeEach(() => {
879
+ metadataService = createMockMetadataService();
880
+ registry = new ToolRegistry();
881
+ registerMetadataTools(registry, { metadataService });
882
+ });
883
+
884
+ it('should support create → add_field → describe → modify → delete lifecycle', async () => {
885
+ // 1. Create object
886
+ await registry.execute({
887
+ type: 'tool-call' as const,
888
+ toolCallId: 's1',
889
+ toolName: 'create_object',
890
+ input: { name: 'invoice', label: 'Invoice' },
891
+ });
892
+
893
+ // 2. Add fields
894
+ await registry.execute({
895
+ type: 'tool-call' as const,
896
+ toolCallId: 's2',
897
+ toolName: 'add_field',
898
+ input: { objectName: 'invoice', name: 'amount', type: 'number', label: 'Amount' },
899
+ });
900
+
901
+ await registry.execute({
902
+ type: 'tool-call' as const,
903
+ toolCallId: 's3',
904
+ toolName: 'add_field',
905
+ input: { objectName: 'invoice', name: 'status', type: 'text', label: 'Status' },
906
+ });
907
+
908
+ // 3. Describe — should show both fields
909
+ const descResult = await registry.execute({
910
+ type: 'tool-call' as const,
911
+ toolCallId: 's4',
912
+ toolName: 'describe_metadata_object',
913
+ input: { objectName: 'invoice' },
914
+ });
915
+ const desc = JSON.parse((descResult.output as any).value);
916
+ expect(desc.fields).toHaveLength(2);
917
+
918
+ // 4. Modify field
919
+ await registry.execute({
920
+ type: 'tool-call' as const,
921
+ toolCallId: 's5',
922
+ toolName: 'modify_field',
923
+ input: {
924
+ objectName: 'invoice',
925
+ fieldName: 'status',
926
+ changes: { type: 'select', label: 'Invoice Status' },
927
+ },
928
+ });
929
+
930
+ // 5. Delete field
931
+ const delResult = await registry.execute({
932
+ type: 'tool-call' as const,
933
+ toolCallId: 's6',
934
+ toolName: 'delete_field',
935
+ input: { objectName: 'invoice', fieldName: 'amount' },
936
+ });
937
+ const del = JSON.parse((delResult.output as any).value);
938
+ expect(del.success).toBe(true);
939
+
940
+ // 6. Describe again — should show only 1 field (status, modified)
941
+ const descResult2 = await registry.execute({
942
+ type: 'tool-call' as const,
943
+ toolCallId: 's7',
944
+ toolName: 'describe_metadata_object',
945
+ input: { objectName: 'invoice' },
946
+ });
947
+ const desc2 = JSON.parse((descResult2.output as any).value);
948
+ expect(desc2.fields).toHaveLength(1);
949
+ expect(desc2.fields[0].name).toBe('status');
950
+ expect(desc2.fields[0].type).toBe('select');
951
+ expect(desc2.fields[0].label).toBe('Invoice Status');
952
+
953
+ // 7. List objects — should show the invoice
954
+ const listResult = await registry.execute({
955
+ type: 'tool-call' as const,
956
+ toolCallId: 's8',
957
+ toolName: 'list_metadata_objects',
958
+ input: {},
959
+ });
960
+ const list = JSON.parse((listResult.output as any).value);
961
+ expect(list.totalCount).toBe(1);
962
+ expect(list.objects[0].name).toBe('invoice');
963
+ });
964
+ });