@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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +16 -0
- package/README.md +293 -0
- package/dist/index.cjs +1871 -1648
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -27
- package/dist/index.d.ts +32 -27
- package/dist/index.js +2137 -1908
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/ai-service.test.ts +30 -1
- package/src/__tests__/auth-and-toolcalling.test.ts +51 -1
- package/src/__tests__/chatbot-features.test.ts +172 -24
- package/src/__tests__/metadata-tools.test.ts +35 -29
- package/src/__tests__/tool-routes.test.ts +191 -0
- package/src/__tests__/vercel-stream-encoder.test.ts +47 -0
- package/src/agents/metadata-assistant-agent.ts +4 -4
- package/src/ai-service.ts +7 -0
- package/src/index.ts +3 -2
- package/src/plugin.ts +85 -35
- package/src/routes/agent-routes.ts +43 -10
- package/src/routes/ai-routes.ts +3 -67
- 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/vercel-stream-encoder.ts +24 -0
- package/src/tools/data-tools.ts +4 -101
- package/src/tools/describe-object.tool.ts +31 -0
- package/src/tools/index.ts +2 -2
- package/src/tools/{list-metadata-objects.tool.ts → list-objects.tool.ts} +9 -9
- package/src/tools/metadata-tools.ts +8 -8
- package/vitest.config.ts +23 -0
- package/src/tools/describe-metadata-object.tool.ts +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/service-ai",
|
|
3
|
-
"version": "4.0.
|
|
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.
|
|
19
|
-
"@objectstack/core": "4.0.
|
|
20
|
-
"@objectstack/spec": "4.0.
|
|
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.
|
|
43
|
+
"@types/node": "^25.6.0",
|
|
44
44
|
"typescript": "^6.0.2",
|
|
45
|
-
"vitest": "^4.1.
|
|
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
|
-
|
|
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
|
|
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
|
|
308
|
-
expect(DATA_TOOL_DEFINITIONS).toHaveLength(
|
|
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
|
|
337
|
+
registerDataTools(registry, { dataEngine });
|
|
340
338
|
});
|
|
341
339
|
|
|
342
|
-
it('should register all
|
|
343
|
-
expect(registry.size).toBe(
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
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');
|
|
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('
|
|
934
|
-
expect(toolNames).toContain('
|
|
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('
|
|
966
|
-
expect(instructions).toContain('
|
|
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 {
|
|
21
|
-
import {
|
|
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
|
-
'
|
|
67
|
-
'
|
|
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:
|
|
90
|
-
{ tool:
|
|
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(
|
|
136
|
-
expect(
|
|
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('
|
|
166
|
-
expect(registry.has('
|
|
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 —
|
|
175
|
-
it('should register both tool sets on the same registry
|
|
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
|
|
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:
|
|
191
|
-
// Metadata tools define: create_object, add_field, modify_field, delete_field,
|
|
192
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
963
|
+
toolName: 'list_objects',
|
|
958
964
|
input: {},
|
|
959
965
|
});
|
|
960
966
|
const list = JSON.parse((listResult.output as any).value);
|