import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentConversationUndoService } from '#/agent/undo/undo'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { ExecutableTool, ToolExecution } from '#/tool/toolContract'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { TOOL_SELECT_FLAG_ENV } from '#/agent/toolSelect/flag'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements'; import { IAgentToolSelectSchemasService } from '#/agent/toolSelect/toolSelectSchemas'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; import '#/agent/tools/select-tools/selectToolsTool'; import { createTestAgent, type TestAgentContext } from '../../harness'; const MCP_ALPHA = 'mcp__srv__alpha'; const DASHBOARD_TOOL = 'dashboard_create'; const DISCLOSURE_CAPABILITIES = { image_in: false, video_in: false, audio_in: false, thinking: false, tool_use: true, max_context_tokens: 128_000, dynamically_loaded_tools: true, } as const; type WireEvent = Extract< TestAgentContext['allEvents'][number], { readonly type: '[wire]' } >; class StubMcpTool implements ExecutableTool> { readonly description: string; readonly parameters: Record = { type: 'object', properties: { query: { type: 'string' } }, additionalProperties: false, }; calls = 0; constructor(readonly name: string) { this.description = `${name} desc`; } resolveExecution(): ToolExecution { return { description: `stub ${this.name}`, approvalRule: this.name, execute: async () => { this.calls += 1; return { output: 'mcp ok' }; }, }; } } function wireEvents(ctx: TestAgentContext, eventName: string): readonly WireEvent[] { return ctx.allEvents.filter( (event): event is WireEvent => event.type === '[wire]' && event.event === eventName, ); } function selectToolsCall(id: string, names: readonly string[]) { return { type: 'function' as const, id, name: 'select_tools', arguments: JSON.stringify({ names }), }; } function toolNames(tools: readonly { readonly name: string }[]): string[] { return tools.map((tool) => tool.name); } function historyText(history: readonly ContextMessage[]): string { return history .flatMap((message) => message.content) .map((part) => (part.type === 'text' ? part.text : '')) .join('\n'); } describe('progressive tool disclosure end-to-end', () => { let ctx: TestAgentContext; let alpha: StubMcpTool; let registration: { dispose(): void } | undefined; beforeEach(async () => { vi.stubEnv(TOOL_SELECT_FLAG_ENV, '1'); ctx = createTestAgent(); ctx.get(IAgentToolSelectService); ctx.get(IAgentToolSelectAnnouncementsService); ctx.get(IAgentToolSelectSchemasService); ctx.get(IAgentToolExecutorService); ctx.configure({ modelCapabilities: DISCLOSURE_CAPABILITIES }); await ctx.restorePersisted(); await ctx.rpc.setPermission({ mode: 'yolo' }); alpha = new StubMcpTool(MCP_ALPHA); registration = ctx .get(IAgentToolRegistryService) .register(alpha, { source: 'mcp', disclosure: 'deferred' }); }); afterEach(async () => { registration?.dispose(); vi.unstubAllEnvs(); await ctx.dispose(); }); it('announces the manifest, loads by name, keeps the top-level table byte-stable, and dispatches on the next step', async () => { ctx.mockNextResponse(selectToolsCall('call_select_1', [MCP_ALPHA])); ctx.mockNextResponse({ type: 'function', id: 'call_alpha_1', name: MCP_ALPHA, arguments: JSON.stringify({ query: 'moon' }), }); ctx.mockNextResponse({ type: 'text', text: 'done' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'try the srv alpha tool' }] }); await ctx.untilTurnEnd(); expect(ctx.llmCalls).toHaveLength(3); const firstWire = ctx.llmCalls[0]!; expect(toolNames(firstWire.tools)).not.toContain(MCP_ALPHA); expect(toolNames(firstWire.tools)).toContain('select_tools'); const announcementText = firstWire.history .map((message) => message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''), ) .join('\n'); expect(announcementText).toContain(''); expect(announcementText).toContain(MCP_ALPHA); const requests = wireEvents(ctx, 'llm.request').filter( (event) => (event.args as { kind?: string }).kind === 'loop', ); expect(requests.length).toBeGreaterThan(0); for (const request of requests) { expect((request.args as { toolSelect?: boolean }).toolSelect).toBe(true); } const secondWire = ctx.llmCalls[1]!; const schemaMessages = secondWire.history.filter( (message) => message.tools?.some((tool) => tool.name === MCP_ALPHA), ); expect(schemaMessages).toHaveLength(1); const alphaFromSchema = schemaMessages[0]!.tools!.find((tool) => tool.name === MCP_ALPHA)!; expect(alphaFromSchema.parameters).toEqual(alpha.parameters); expect(secondWire.tools).toEqual(firstWire.tools); expect(wireEvents(ctx, 'llm.tools_snapshot')).toHaveLength(1); expect(alpha.calls).toBe(1); }); it('loads and dispatches a user tool registered through the domain service', async () => { ctx.get(IAgentUserToolService).register({ name: DASHBOARD_TOOL, description: 'Create a dashboard.', parameters: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'], additionalProperties: false, }, disclosure: 'deferred', }); ctx.mockNextResponse(selectToolsCall('call_select_1', [DASHBOARD_TOOL])); ctx.mockNextResponse({ type: 'function', id: 'call_dashboard_1', name: DASHBOARD_TOOL, arguments: JSON.stringify({ title: 'Operations' }), }); ctx.mockNextResponse({ type: 'text', text: 'done' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'create a dashboard' }] }); await ctx.untilToolCall({ output: 'dashboard-created' }); await ctx.untilTurnEnd(); const firstWire = ctx.llmCalls[0]!; expect(toolNames(firstWire.tools)).not.toContain(DASHBOARD_TOOL); expect(historyText(firstWire.history)).toContain(DASHBOARD_TOOL); const secondWire = ctx.llmCalls[1]!; const injected = secondWire.history.find((message) => message.tools?.some((tool) => tool.name === DASHBOARD_TOOL), ); expect(injected?.tools?.find((tool) => tool.name === DASHBOARD_TOOL)?.parameters).toEqual({ type: 'object', properties: { title: { type: 'string' } }, required: ['title'], additionalProperties: false, }); expect(secondWire.tools).toEqual(firstWire.tools); expect(historyText(ctx.get(IAgentContextMemoryService).get())).toContain( `Loaded: ${DASHBOARD_TOOL}`, ); expect(historyText(ctx.get(IAgentContextMemoryService).get())).toContain( 'dashboard-created', ); }); it('keeps the selected schema across undo and reports it as already available on reselect', async () => { ctx.get(IAgentContextMemoryService).append({ role: 'user', content: [{ type: 'text', text: 'earlier question' }], toolCalls: [], origin: { kind: 'user' }, }); ctx.mockNextResponse(selectToolsCall('call_select_1', [MCP_ALPHA])); ctx.mockNextResponse({ type: 'text', text: 'alpha is loaded' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'load alpha' }] }); await ctx.untilTurnEnd(); await ctx.get(IAgentConversationUndoService).undo(1); const afterUndo = ctx.get(IAgentContextMemoryService).get(); expect(afterUndo.some((message) => message.tools?.some((tool) => tool.name === MCP_ALPHA))).toBe( true, ); ctx.mockNextResponse(selectToolsCall('call_select_2', [MCP_ALPHA])); ctx.mockNextResponse({ type: 'text', text: 'reloaded' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'load alpha again' }] }); await ctx.untilTurnEnd(); const afterReload = ctx.get(IAgentContextMemoryService).get(); expect( afterReload.some((message) => message.tools?.some((tool) => tool.name === MCP_ALPHA)), ).toBe(true); expect(historyText(afterReload)).toContain('Already available: mcp__srv__alpha'); expect(historyText(afterReload)).not.toContain('Loaded: mcp__srv__alpha'); }); });