import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { rgPath } from '@vscode/ripgrep' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ResolvedMcpServer } from '../capabilities/capability-service' import type { BrowserToolService } from '../browser/browser-model-tools' import { BrowserService } from '../browser/browser-service' import { browserTabIdSchema } from '../../shared/contracts' import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver' import { LocalWorkspaceAccess, type WorkspaceAccess } from '../workspace' import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway' import { scopedDataToolByName } from '../../shared/scoped-data-tools' import { LocalDirectModelProcessService, type DirectModelProcessService, type ProcessExecuteResult } from './direct-model-process-service' import { DirectModelSubagentService, type DirectModelSubagentResult } from '../assistant/direct-model-subagent-service' import { SubagentScheduler } from '../assistant/subagent-scheduler' import type { PagedOutputPage } from './paged-output-store' const mocks = vi.hoisted(() => { const tasks = { callToolStream: vi.fn(), cancelTask: vi.fn() } const client = { connect: vi.fn(), listTools: vi.fn(), getServerCapabilities: vi.fn(), callTool: vi.fn(), experimental: { tasks }, close: vi.fn() } return { client, tasks, Client: vi.fn(function Client( _info: unknown, _options?: unknown ) { void _info void _options return client }), createMcpTransport: vi.fn(() => ({ kind: 'test-transport' })) } }) vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ Client: mocks.Client })) vi.mock('../capabilities/mcp-client-transport', () => ({ createMcpTransport: mocks.createMcpTransport })) import { ModelToolProvider, RecoverableModelToolError, type ModelToolCallContext, type ModelSubagentRequestContext, type ModelToolResult } from './model-tool-provider' const temporaryDirectories: string[] = [] const png = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]).toString('base64') const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]).toString('base64') const firstBrowserTabId = browserTabIdSchema.parse( '00000000-0000-4000-8000-000000000201' ) const secondBrowserTabId = browserTabIdSchema.parse( '00000000-0000-4000-8000-000000000202' ) const toolContext = { conversationId: 'provider-test-conversation', browserTabId: firstBrowserTabId, workMode: 'execute' } satisfies ModelToolCallContext function createBrowserService(): BrowserToolService { return { getOrigin: vi.fn(() => 'https://example.com'), navigate: vi.fn(async (_conversationId, url) => ({ url, origin: 'https://example.com' })), snapshot: vi.fn(async () => ({ url: 'https://example.com/', title: 'Example', nodes: [], truncated: false })), click: vi.fn(async () => undefined), type: vi.fn(async () => undefined), select: vi.fn(async () => undefined), back: vi.fn(async () => ({ url: 'https://previous.example/', origin: 'https://previous.example' })), screenshot: vi.fn(async () => ({ type: 'image' as const, mimeType: 'image/jpeg' as const, data: jpeg })), releaseConversation: vi.fn(async () => undefined) } } function createMcpServer( allowDynamicTools = false ): ResolvedMcpServer { return { id: 'd2ef774b-146c-4467-a909-6feb112a9c2c', name: 'Search MCP', description: '', enabled: true, allowDynamicTools, assignments: ['model'], secretConfigured: false, transport: 'stdio', command: 'node', args: ['server.js'] } } async function createWorkspace(): Promise { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-tools-')) temporaryDirectories.push(directory) return directory } describe('ModelToolProvider', () => { beforeEach(() => { vi.clearAllMocks() mocks.client.connect.mockResolvedValue(undefined) mocks.client.listTools.mockResolvedValue({ tools: [] }) mocks.client.getServerCapabilities.mockReturnValue({ tools: { listChanged: false } }) mocks.client.callTool.mockResolvedValue({ content: [{ type: 'text', text: 'MCP result' }] }) mocks.tasks.callToolStream.mockImplementation(async function* () { yield { type: 'result', result: { content: [{ type: 'text', text: 'MCP task result' }] } } }) mocks.tasks.cancelTask.mockResolvedValue({}) mocks.client.close.mockResolvedValue(undefined) }) afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) .map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) it('passes the launch environment provider to new stdio MCP connections', async () => { const workspace = await createWorkspace() const launchEnvironmentProvider = vi.fn(() => Object.freeze({ PATH: '/managed-tools:/usr/bin' }) ) const provider = new ModelToolProvider( workspace, [createMcpServer()], undefined, undefined, false, {}, launchEnvironmentProvider ) await provider.listTools(toolContext, new AbortController().signal) expect(mocks.createMcpTransport).toHaveBeenCalledWith( expect.objectContaining({ transport: 'stdio' }), launchEnvironmentProvider ) expect(launchEnvironmentProvider).not.toHaveBeenCalled() await provider.dispose() }) it.each(['connect', 'listTools'] as const)( 'isolates MCP %s failures and retries without reconnecting healthy servers', async (failureMethod) => { const workspace = await createWorkspace() const failure = new Error('server unavailable') const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const failedClient = { ...mocks.client, connect: vi.fn().mockResolvedValue(undefined), listTools: vi.fn().mockResolvedValue({ tools: [] }), close: vi.fn().mockResolvedValue(undefined) } failedClient[failureMethod].mockRejectedValueOnce(failure) mocks.Client.mockImplementationOnce(function () { return failedClient }) mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'search', inputSchema: { type: 'object' } }] }) const provider = new ModelToolProvider(workspace, [ createMcpServer(), { ...createMcpServer(), id: 'healthy', name: 'Healthy' } ], createBrowserService()) const signal = new AbortController().signal try { const tools = await provider.listTools(toolContext, signal) expect(tools).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'workspace_read_text' }), expect.objectContaining({ name: 'browser_snapshot' }), expect.objectContaining({ serverName: 'Healthy' }) ])) expect(tools.filter((tool) => tool.source === 'mcp')).toHaveLength(1) expect(failedClient.close).toHaveBeenCalledOnce() expect(mocks.client.close).not.toHaveBeenCalled() expect(warning).toHaveBeenCalledWith( expect.stringContaining('Search MCP'), expect.any(Error) ) const healthyName = tools.find((tool) => tool.serverName === 'Healthy')!.name for (let index = 0; index < 2; index += 1) { await provider.callTool(healthyName, {}, signal, toolContext) } await expect(provider.callTool('unknown_mcp', {}, signal, toolContext)) .rejects.toThrow('未知工具') // No 30-second failed-server reconnect may precede a known healthy call. expect(mocks.Client).toHaveBeenCalledTimes(2) expect(mocks.client.connect).toHaveBeenCalledOnce() expect(mocks.client.listTools).toHaveBeenCalledOnce() expect(failedClient[failureMethod]).toHaveBeenCalledOnce() expect(mocks.client.callTool).toHaveBeenCalledTimes(2) const recovered = await provider.listTools(toolContext, signal) expect(recovered.filter((tool) => tool.source === 'mcp')).toHaveLength(2) expect(mocks.Client).toHaveBeenCalledTimes(3) expect(mocks.client.connect).toHaveBeenCalledTimes(2) await provider.callTool( tools.find((tool) => tool.serverName === 'Healthy')!.name, {}, signal, toolContext ) expect(mocks.client.callTool).toHaveBeenCalledTimes(3) } finally { await provider.dispose() warning.mockRestore() } } ) it('clears discovered bindings on disposal and does not carry them to a replacement configuration', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'search', inputSchema: { type: 'object' } }] }) const provider = new ModelToolProvider(workspace, [createMcpServer()]) const replacement = new ModelToolProvider(workspace, []) const signal = new AbortController().signal try { const tools = await provider.listTools(toolContext, signal) const name = tools.find((tool) => tool.source === 'mcp')!.name await provider.callTool(name, {}, signal, toolContext) await provider.dispose() await expect(provider.callTool(name, {}, signal, toolContext)) .rejects.toThrow('未知工具') expect((await replacement.listTools(toolContext, signal)) .filter((tool) => tool.source === 'mcp')).toEqual([]) await expect(replacement.callTool(name, {}, signal, toolContext)) .rejects.toThrow('未知工具') expect(mocks.client.callTool).toHaveBeenCalledOnce() expect(mocks.Client).toHaveBeenCalledOnce() } finally { await provider.dispose() await replacement.dispose() } }) it('loads tools from more than sixteen MCP servers', async () => { mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'search', inputSchema: { type: 'object' } }] }) const provider = new ModelToolProvider(await createWorkspace(), Array.from({ length: 17 }, (_, index) => ({ ...createMcpServer(), id: `server-${index}`, name: `Server ${index}` })) ) try { const tools = await provider.listTools(toolContext, new AbortController().signal) expect(tools.filter((tool) => tool.source === 'mcp')).toHaveLength(17) expect(mocks.client.connect).toHaveBeenCalledTimes(17) } finally { await provider.dispose() } }) it('preserves cancellation during MCP discovery and permits a later retry', async () => { const controller = new AbortController() const reason = new Error('cancel discovery') mocks.client.connect.mockImplementationOnce(async () => { controller.abort(reason) throw reason }) const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer()]) try { await expect(provider.listTools(toolContext, controller.signal)).rejects.toBe(reason) expect(mocks.client.close).toHaveBeenCalledOnce() await expect(provider.listTools(toolContext, new AbortController().signal)) .resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'workspace_read_text' }) ])) expect(mocks.client.connect).toHaveBeenCalledTimes(2) } finally { await provider.dispose() } }) it('omits failed dynamic catalogs and retries refresh while healthy tools remain usable', async () => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) mocks.client.getServerCapabilities.mockReturnValue({ tools: { listChanged: true } }) mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'search', inputSchema: { type: 'object' } }] }) const provider = new ModelToolProvider(await createWorkspace(), [ createMcpServer(true), { ...createMcpServer(), id: 'healthy', name: 'Healthy' } ]) const signal = new AbortController().signal try { const originalTools = await provider.listTools(toolContext, signal) const options = mocks.Client.mock.calls[0]![1] as { listChanged: { tools: { onChanged(error: Error | null): void } } } options.listChanged.tools.onChanged(null) mocks.client.listTools.mockRejectedValueOnce(new Error('refresh unavailable')) const tools = await provider.listTools(toolContext, signal) expect(tools.filter((tool) => tool.source === 'mcp')).toEqual([ expect.objectContaining({ serverName: 'Healthy' }) ]) await expect(provider.callTool( originalTools.find((tool) => tool.serverName === 'Search MCP')!.name, {}, signal, toolContext )).rejects.toThrow('未知工具') await provider.callTool( tools.find((tool) => tool.serverName === 'Healthy')!.name, {}, signal, toolContext ) expect(mocks.client.listTools).toHaveBeenCalledTimes(3) expect(mocks.client.close).not.toHaveBeenCalled() const recovered = await provider.listTools(toolContext, signal) expect(recovered.filter((tool) => tool.source === 'mcp')).toHaveLength(2) expect(mocks.client.connect).toHaveBeenCalledTimes(2) options.listChanged.tools.onChanged(null) const controller = new AbortController() const reason = new Error('cancel refresh') mocks.client.listTools.mockImplementationOnce(async () => { controller.abort(reason) throw reason }) await expect(provider.listTools(toolContext, controller.signal)).rejects.toBe(reason) } finally { await provider.dispose() warning.mockRestore() } }) it('provides ripgrep, paged reads, and patch tools', async () => { const workspace = await createWorkspace() await mkdir(join(workspace, 'docs')) await writeFile(join(workspace, 'docs', 'note.txt'), 'hello', 'utf8') await writeFile(join(workspace, 'docs', 'search.txt'), '中target\n', 'utf8') const provider = new ModelToolProvider( workspace, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath } ) const signal = new AbortController().signal await expect(provider.listTools(toolContext, signal)).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'workspace_rg' }), expect.objectContaining({ name: 'workspace_read_text' }), expect.objectContaining({ name: 'workspace_apply_patch' }) ]) ) await expect( provider.callTool( 'workspace_read_text', { path: 'docs/note.txt' }, signal, toolContext ) ).resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('1: hello') }] }) const listing = await provider.callTool( 'workspace_rg', { args: ['--files', '--path-separator=/', 'docs'] }, signal, toolContext ) expect(listing.parts).toEqual([ expect.objectContaining({ type: 'text', text: expect.stringContaining('docs/note.txt') }) ]) const matches = await provider.callTool( 'workspace_rg', { args: ['--path-separator=/', 'target', 'docs'] }, signal, toolContext ) expect(matches.parts).toEqual([ expect.objectContaining({ type: 'text', text: expect.stringContaining('docs/search.txt:1:中target') }) ]) const written = await provider.callTool( 'workspace_apply_patch', { patch: [ '*** Begin Patch', '*** Add File: docs/output.txt', '+saved', '*** End Patch' ].join('\n') }, signal, toolContext ) expect(written.parts).toEqual([ expect.objectContaining({ type: 'text', text: expect.stringContaining('docs/output.txt') }) ]) await expect( readFile(join(workspace, 'docs', 'output.txt'), 'utf8') ).resolves.toBe('saved') }) it('recovers an absolute local path and reads the corrected relative path', async () => { const workspace = await createWorkspace() await writeFile(join(workspace, 'note.txt'), 'corrected read') const provider = new ModelToolProvider(workspace) const signal = new AbortController().signal try { await expect(provider.callTool('workspace_read_text', { path: join(workspace, 'note.txt') }, signal, toolContext)).rejects.toMatchObject({ name: 'RecoverableModelToolError', nextAction: expect.stringContaining('workspace-relative path') }) await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext)) .resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('corrected read') }] }) await expect(provider.callTool('workspace_read_text', { path: 'missing.txt' }, signal, toolContext)) .rejects.toMatchObject({ name: 'RecoverableModelToolError', cause: { code: 'ENOENT' }, nextAction: expect.stringContaining('Do not retry identical arguments') }) } finally { await provider.dispose() } }) it('recovers invalid search inputs and actual rg regex failures', async () => { const workspace = await createWorkspace() await writeFile(join(workspace, 'note.txt'), '[literal') const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath }) const signal = new AbortController().signal try { for (const args of [ { args: 'x' }, { args: [1] }, { args: ['x'], extra: true } ]) { await expect(provider.callTool('workspace_rg', args, signal, toolContext)).rejects.toMatchObject({ name: 'RecoverableModelToolError', nextAction: expect.stringContaining('Correct native rg args') }) } await expect(provider.callTool('workspace_rg', { args: ['-F', '[', 'note.txt'] }, signal, toolContext)) .resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('1:[literal') }] }) } finally { await provider.dispose() } }) it('bounds expected remote read errors without recovering disconnects or programmer errors', async () => { const access = new LocalWorkspaceAccess(await createWorkspace()) const readText = vi.spyOn(access, 'readText') const provider = new ModelToolProvider(access) const signal = new AbortController().signal try { for (const code of ['ENOENT', 'EACCES', 'invalid-path', 'invalid-utf8', 'special-file']) { const failure = Object.assign(new Error('detail'.repeat(1_000)), { data: { code } }) readText.mockRejectedValueOnce(failure) const result = await provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext) .catch((error: unknown) => error) expect(result).toBeInstanceOf(RecoverableModelToolError) const error = result as RecoverableModelToolError expect(Buffer.byteLength(error.message)).toBeLessThanOrEqual(2_021) expect(error.cause).toBe(failure) expect(error.nextAction).not.toContain('process_execute') } for (const failure of [ new TypeError('programmer error'), new Error('remote disconnected'), Object.assign(new Error('closed'), { code: 'closed' }), Object.assign(new Error('stale workspace'), { data: { code: 'stale-workspace' } }) ]) { readText.mockRejectedValueOnce(failure) await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext)) .rejects.toBe(failure) } } finally { await provider.dispose() } }) it('leaves a missing rg executable fatal', async () => { const workspace = await createWorkspace() const access = new LocalWorkspaceAccess(workspace) const provider = new ModelToolProvider(access, [], undefined, undefined, false, { ripgrepExecutablePath: join(workspace, 'missing-rg.exe') }) try { const error = await provider.callTool('workspace_rg', { args: ['x'] }, new AbortController().signal, toolContext) .catch((error: unknown) => error) expect(error).toBeInstanceOf(Error) expect(error).not.toBeInstanceOf(RecoverableModelToolError) expect(error).toMatchObject({ cause: { code: 'ENOENT' } }) } finally { await provider.dispose() } }) it.each(['workspace_read_text', 'workspace_rg'])('keeps %s cancellation fatal', async (name) => { const access = new LocalWorkspaceAccess(await createWorkspace()) const provider = new ModelToolProvider(access, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath }) const operation = name === 'workspace_rg' ? vi.spyOn(access, 'stat') : vi.spyOn(access, 'readText') const args = name === 'workspace_rg' ? { args: ['x', 'note.txt'] } : { path: 'note.txt' } const context = { ...toolContext, workMode: 'ask' as const } try { const abortError = new DOMException('cancelled', 'AbortError') operation.mockRejectedValueOnce(abortError) await expect(provider.callTool(name, args, new AbortController().signal, context)).rejects.toBe(abortError) const controller = new AbortController() const reason = new Error('cancelled during access') operation.mockImplementationOnce(async () => { controller.abort(reason) throw Object.assign(new Error('missing'), { code: 'ENOENT' }) }) await expect(provider.callTool(name, args, controller.signal, context)).rejects.toBe(reason) await expect(provider.callTool(name, {}, controller.signal, toolContext)).rejects.toBe(reason) } finally { await provider.dispose() } }) it('suggests process_execute only when available in local model Execute', async () => { const workspace = await createWorkspace() const access = new LocalWorkspaceAccess(workspace) const identity = await access.getIdentity() const processService = { getCapability: vi.fn(async () => ({ available: true, shell: { kind: 'powershell', label: 'pwsh' } })), dispose: vi.fn(async () => {}) } as unknown as DirectModelProcessService const provider = new ModelToolProvider(access, [], undefined, undefined, false, { processService }) try { for (const [context, available] of [ [{ ...toolContext, runtimeTarget: 'model' as const }, true], [{ ...toolContext, runtimeTarget: 'model' as const, workMode: 'ask' as const }, false], [toolContext, false], [{ ...toolContext, runtimeTarget: 'model' as const, executionSpaceIdentity: `${identity.id}-other` }, false] ] as const) { const error = await provider.callTool('workspace_read_text', { path: workspace }, new AbortController().signal, context) .catch((error: unknown) => error) as RecoverableModelToolError expect(error).toBeInstanceOf(RecoverableModelToolError) expect(error.nextAction.includes('process_execute')).toBe(available) } vi.mocked(processService.getCapability).mockResolvedValueOnce({ available: false, reason: 'unavailable' }) await expect(provider.callTool('workspace_read_text', { path: workspace }, new AbortController().signal, { ...toolContext, runtimeTarget: 'model' })).rejects.toMatchObject({ nextAction: expect.not.stringContaining('process_execute') }) } finally { await provider.dispose() } }) it('delegates paged workspace reads and disposal to WorkspaceAccess', async () => { const readText = vi.fn(async () => ({ path: 'remote.txt', name: 'remote.txt', content: 'remote content', size: 14, offsetBytes: 0, bytesRead: 14, truncated: false })) const listDirectory = vi.fn(async () => ({ path: '', entries: [ { name: 'remote.txt', path: 'remote.txt', type: 'file' as const } ], truncated: false })) const writeTextAtomic = vi.fn(async () => ({ path: 'output.txt', bytesWritten: 5 })) const dispose = vi.fn(async () => undefined) const access = { getIdentity: vi.fn(), listDirectory, stat: vi.fn(), readText, writeTextAtomic, search: vi.fn(), getChanges: vi.fn(), dispose } as unknown as WorkspaceAccess const provider = new ModelToolProvider(access) const signal = new AbortController().signal await expect( provider.callTool( 'workspace_read_text', { path: 'remote.txt' }, signal, toolContext ) ).resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('1: remote content') }] }) expect(readText).toHaveBeenCalledWith( expect.objectContaining({ path: 'remote.txt', maximumBytes: 128 * 1024, allowTruncated: true, signal }) ) expect(listDirectory).not.toHaveBeenCalled() expect(writeTextAtomic).not.toHaveBeenCalled() await provider.dispose() expect(dispose).toHaveBeenCalledOnce() }) it('denies spoofed mutating tool calls in Ask before reaching sinks', async () => { const readText = vi.fn(async () => ({ path: 'remote.txt', name: 'remote.txt', content: 'remote content', size: 14 })) const listDirectory = vi.fn(async () => ({ path: '', entries: [], truncated: false })) const writeTextAtomic = vi.fn() const access = { getIdentity: vi.fn(), listDirectory, stat: vi.fn(), readText, writeTextAtomic, search: vi.fn(), getChanges: vi.fn(), dispose: vi.fn(async () => undefined) } as unknown as WorkspaceAccess const browserService = createBrowserService() const createMagicNote = vi.fn() const callGoodBuddyConfigTool = vi.fn() const gateway = { createMagicNote, callGoodBuddyConfigTool, getAvailableToolNames: vi.fn(() => [ 'note_create', 'goodbuddy_config_apply' ]) } as unknown as KnowledgeMcpGateway const provider = new ModelToolProvider( access, [createMcpServer()], browserService, gateway ) const signal = new AbortController().signal const askContext = { conversationId: 'spoofed-ask-tools', workMode: 'ask', knowledgeCapabilityToken: 'main-only-token' } satisfies ModelToolCallContext const denial = 'Ask 模式仅允许调用已声明的只读工具' for (const [name, argumentsValue] of [ [ 'workspace_apply_patch', { patch: '*** Begin Patch\n*** End Patch' } ], ['browser_click', { ref: 'b_target' }], ['note_create', { title: 'blocked' }], [ 'goodbuddy_config_apply', { planId: '00000000-0000-4000-8000-000000000702' } ], ['mcp_spoofed_mutation', {}] ] as const) { await expect( provider.callTool( name, argumentsValue, signal, askContext ) ).rejects.toThrow(denial) } expect(writeTextAtomic).not.toHaveBeenCalled() expect(browserService.click).not.toHaveBeenCalled() expect(createMagicNote).not.toHaveBeenCalled() expect(callGoodBuddyConfigTool).not.toHaveBeenCalled() expect(mocks.Client).not.toHaveBeenCalled() const writeTool = { name: 'workspace_apply_patch', displayName: '应用工作区补丁', description: 'write', inputSchema: {}, source: 'builtin' } as const expect(() => provider.getApproval( writeTool, { patch: '*** Begin Patch\n*** End Patch' }, 'output.txt', askContext ) ).toThrow(denial) expect(() => provider.getApproval( { ...writeTool, name: 'browser_click', displayName: '点击浏览器元素' }, { ref: 'b_target' }, 'b_target', askContext ) ).toThrow(denial) expect(browserService.getOrigin).not.toHaveBeenCalled() await expect( provider.callTool( 'workspace_read_text', { path: 'remote.txt' }, signal, askContext ) ).resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('1: remote content') }] }) expect(readText).toHaveBeenCalledOnce() expect(listDirectory).not.toHaveBeenCalled() }) it('exposes scoped reads in Ask and Magic Notes writes only in Execute', async () => { const workspace = await createWorkspace() const search = vi.fn(async () => []) const searchMagicNotes = vi.fn(() => []) const listLibraries = vi.fn(() => [ { id: 'library-1', name: '产品知识' } ]) const listMagicNotes = vi.fn(() => []) const getMagicNote = vi.fn(() => ({ id: '00000000-0000-4000-8000-000000000701' })) const createMagicNote = vi.fn(() => ({ id: '00000000-0000-4000-8000-000000000701' })) const callGoodBuddyConfigTool = vi.fn(async () => ({ capabilities: { server: 'goodbuddy_config' } })) const gateway = { listLibraries, search, searchMagicNotes, listMagicNotes, getMagicNote, createMagicNote, callGoodBuddyConfigTool, getAvailableToolNames: vi.fn(() => [ 'knowledge_list', 'knowledge_search', 'note_list', 'note_get', 'note_search', 'note_create', 'note_update', 'note_entry_create', 'note_entry_update', 'note_entry_delete', 'note_delete', 'goodbuddy_config_capabilities', 'goodbuddy_config_get', 'goodbuddy_config_plan', 'goodbuddy_config_apply' ]) } as unknown as KnowledgeMcpGateway const provider = new ModelToolProvider( workspace, [], undefined, gateway ) const signal = new AbortController().signal const askContext = { conversationId: 'knowledge-ask', workMode: 'ask', knowledgeCapabilityToken: 'main-only-token' } satisfies ModelToolCallContext const askTools = await provider.listTools(askContext, signal) expect(askTools.map((tool) => tool.name)).toEqual([ 'workspace_read_text', 'knowledge_list', 'knowledge_search', 'note_list', 'note_get', 'note_search', 'goodbuddy_config_capabilities', 'goodbuddy_config_get', 'goodbuddy_config_plan' ]) expect( JSON.stringify( askTools.find((tool) => tool.name === 'knowledge_search') ?.inputSchema ) ).not.toContain('library') await provider.callTool( 'knowledge_list', {}, signal, askContext ) expect(listLibraries).toHaveBeenCalledWith( 'main-only-token', {} ) await provider.callTool( 'knowledge_search', { query: 'scope query', limit: 4 }, signal, askContext ) expect(search).toHaveBeenCalledWith( 'main-only-token', { query: 'scope query', limit: 4 }, signal ) await provider.callTool( 'note_search', { query: '发布计划', limit: 3 }, signal, askContext ) expect(searchMagicNotes).toHaveBeenCalledWith( 'main-only-token', { query: '发布计划', limit: 3 }, signal ) await provider.callTool('note_list', {}, signal, askContext) expect(listMagicNotes).toHaveBeenCalledWith('main-only-token', {}) await provider.callTool( 'note_get', { noteId: '00000000-0000-4000-8000-000000000701' }, signal, askContext ) expect(getMagicNote).toHaveBeenCalledWith('main-only-token', { noteId: '00000000-0000-4000-8000-000000000701' }) await provider.callTool( 'goodbuddy_config_capabilities', {}, signal, askContext ) expect(callGoodBuddyConfigTool).toHaveBeenCalledWith( 'main-only-token', 'goodbuddy_config_capabilities', {}, signal ) await expect( provider.listTools( { conversationId: 'knowledge-empty', workMode: 'ask' }, signal ) ).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'workspace_read_text' }) ]) ) const executeTools = await provider.listTools( { ...askContext, workMode: 'execute' }, signal ) expect(executeTools.map((tool) => tool.name)).toEqual( expect.arrayContaining([ 'workspace_read_text', 'workspace_apply_patch', 'knowledge_list', 'knowledge_search', 'note_search', 'note_create', 'note_update', 'note_entry_create', 'note_entry_update', 'note_entry_delete', 'note_delete', 'goodbuddy_config_capabilities', 'goodbuddy_config_get', 'goodbuddy_config_plan', 'goodbuddy_config_apply' ]) ) await provider.callTool( 'note_create', { title: '发布计划', content: '核对构建产物' }, signal, { ...askContext, workMode: 'execute' } ) expect(createMagicNote).toHaveBeenCalledWith('main-only-token', { title: '发布计划', content: '核对构建产物' }) expect( executeTools.find((tool) => tool.name === 'note_create')?.inputSchema ).toMatchObject({ properties: { content: { type: 'string', maxLength: 48_000 } }, required: ['title'] }) const deleteTool = executeTools.find( (tool) => tool.name === 'note_delete' )! expect( provider.getApproval( deleteTool, { noteId: '00000000-0000-4000-8000-000000000701', expectedRevision: 1 }, '{"expectedRevision":1}', { ...askContext, workMode: 'execute' } ) ).toMatchObject({ scopeKey: 'model:magic-notes:note_delete', allowPermanent: false, description: expect.stringContaining('永久删除') }) const configApplyTool = executeTools.find( (tool) => tool.name === 'goodbuddy_config_apply' )! expect( provider.getApproval( configApplyTool, { planId: '00000000-0000-4000-8000-000000000702' }, '{"planId":"00000000-0000-4000-8000-000000000702"}', { ...askContext, workMode: 'execute' } ) ).toMatchObject({ scopeKey: 'model:goodbuddy-config:apply', allowPermanent: false }) }) it('reserves the granted scoped data tool slots for Execute', async () => { const workspace = await createWorkspace() const gateway = { listLibraries: vi.fn(() => []), search: vi.fn(async () => []), searchMagicNotes: vi.fn(() => []), getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()]) } as unknown as KnowledgeMcpGateway const context = { conversationId: 'knowledge-capacity', runtimeTarget: 'model', workMode: 'execute', knowledgeCapabilityToken: 'main-only-token' } satisfies ModelToolCallContext const createTools = (count: number) => Array.from({ length: count }, (_, index) => ({ name: `remote_tool_${index}`, description: 'Remote tool', inputSchema: { type: 'object', properties: {}, additionalProperties: false } })) mocks.client.listTools.mockResolvedValueOnce({ tools: createTools(100 - 4 - scopedDataToolByName.size) }) const validProvider = new ModelToolProvider( workspace, [createMcpServer()], undefined, gateway, false, { ripgrepExecutablePath: rgPath } ) await expect( validProvider.listTools(context, new AbortController().signal) ).resolves.toHaveLength(100) await validProvider.dispose() mocks.client.listTools.mockResolvedValueOnce({ tools: createTools(100 - 4 - scopedDataToolByName.size + 1) }) const overflowingProvider = new ModelToolProvider( workspace, [createMcpServer()], undefined, gateway, false, { ripgrepExecutablePath: rgPath } ) await expect( overflowingProvider.listTools( context, new AbortController().signal ) ).resolves.toHaveLength(4 + scopedDataToolByName.size) await overflowingProvider.dispose() }) it('rechecks cached and refreshed MCP catalogs against each request scope without shrinking the cache', async () => { const gateway = { getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()]) } as unknown as KnowledgeMcpGateway const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer(true)], undefined, gateway, false, { ripgrepExecutablePath: rgPath }) const unscoped = { ...toolContext, runtimeTarget: 'model' as const } const scoped = { ...unscoped, knowledgeCapabilityToken: 'scoped' } const signal = new AbortController().signal const catalog = (count: number) => ({ tools: Array.from({ length: count }, (_, index) => ({ name: `remote_${index}`, inputSchema: { type: 'object' } })) }) mocks.client.getServerCapabilities.mockReturnValue({ tools: { listChanged: true } }) mocks.client.listTools.mockResolvedValue(catalog(90)) try { // Start with the narrower request to ensure it cannot poison later discovery. await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size) const tools = await provider.listTools(unscoped, signal) expect(tools).toHaveLength(94) const name = tools.find((tool) => tool.source === 'mcp')!.name await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size) await expect(provider.callTool(name, {}, signal, scoped)).rejects.toThrow('未知工具') await expect(provider.listTools(unscoped, signal)).resolves.toHaveLength(94) await expect(provider.callTool(name, {}, signal, unscoped)).resolves.toBeDefined() expect(mocks.client.connect).toHaveBeenCalledOnce() expect(mocks.client.listTools).toHaveBeenCalledOnce() const options = mocks.Client.mock.calls[0]![1] as { listChanged: { tools: { onChanged(error: Error | null): void } } } mocks.client.listTools.mockResolvedValue(catalog(96)) options.listChanged.tools.onChanged(null) await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size) await expect(provider.listTools(unscoped, signal)).resolves.toHaveLength(100) expect(mocks.client.listTools).toHaveBeenCalledTimes(2) mocks.client.listTools.mockResolvedValue(catalog(100 - 4 - scopedDataToolByName.size)) options.listChanged.tools.onChanged(null) await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(100) expect(mocks.client.connect).toHaveBeenCalledOnce() } finally { await provider.dispose() } }) it('enforces the aggregate MCP limit when a cached catalog gains scoped tools', async () => { const gateway = { getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()]) } as unknown as KnowledgeMcpGateway mocks.client.listTools.mockResolvedValue({ tools: Array.from({ length: 40 }, (_, index) => ({ name: `remote_${index}`, inputSchema: { type: 'object' } })) }) const provider = new ModelToolProvider(await createWorkspace(), [ createMcpServer(), { ...createMcpServer(), id: 'second', name: 'Second' } ], undefined, gateway) const signal = new AbortController().signal try { await expect(provider.listTools(toolContext, signal)).resolves.toHaveLength(82) await expect(provider.listTools({ ...toolContext, knowledgeCapabilityToken: 'scoped' }, signal)) .rejects.toThrow('100') await expect(provider.listTools(toolContext, signal)).resolves.toHaveLength(82) expect(mocks.client.listTools).toHaveBeenCalledTimes(2) } finally { await provider.dispose() } }) it('loads every paginated MCP tool before exposing the catalog', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockImplementation( async (params?: { cursor?: string }) => params?.cursor === '' ? { tools: [ { name: 'second', inputSchema: { type: 'object', properties: {} } } ] } : { tools: [ { name: 'first', inputSchema: { type: 'object', properties: {} } } ], nextCursor: '' } ) const provider = new ModelToolProvider(workspace, [ createMcpServer() ]) const tools = await provider.listTools( toolContext, new AbortController().signal ) expect(tools).toEqual( expect.arrayContaining([ expect.objectContaining({ displayName: 'Search MCP / first' }), expect.objectContaining({ displayName: 'Search MCP / second' }) ]) ) expect(mocks.client.listTools).toHaveBeenNthCalledWith( 2, { cursor: '' }, expect.objectContaining({ timeout: expect.any(Number) }) ) await provider.dispose() }) it('provides local process execution only to direct-model Execute requests', async () => { const workspace = await createWorkspace() const processService = { getCapability: vi.fn(async () => ({ available: true as const, shell: { kind: 'powershell' as const, label: 'pwsh' } })), execute: vi.fn(async () => ({ shell: { kind: 'powershell' as const, label: 'pwsh' }, cwd: '.', exitCode: 7, durationMs: 12, stdout: 'out', stderr: 'err', stdoutTruncated: false, stderrTruncated: false })), readOutput: vi.fn(), releaseConversation: vi.fn(async () => undefined), dispose: vi.fn(async () => undefined) } satisfies DirectModelProcessService const provider = new ModelToolProvider( workspace, [], undefined, undefined, false, { processService } ) const identity = await new LocalWorkspaceAccess( workspace ).getIdentity() const executeContext = { conversationId: 'process-execute', workMode: 'execute', runtimeTarget: 'model', executionSpaceIdentity: identity.id, delegationDepth: 0 } satisfies ModelToolCallContext await expect( provider.listTools(executeContext, new AbortController().signal) ).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'process_execute' }) ]) ) await expect( provider.listTools( { ...executeContext, conversationId: 'process-ask', workMode: 'ask' }, new AbortController().signal ) ).resolves.not.toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'process_execute' }) ]) ) await expect( provider.callTool( 'process_execute', { command: 'exit 7' }, new AbortController().signal, executeContext ) ).resolves.toMatchObject({ parts: [ { type: 'text', text: expect.stringContaining('"exitCode":7') } ] }) processService.execute.mockResolvedValueOnce({ shell: { kind: 'powershell', label: 'pwsh' }, cwd: '.', exitCode: 0, durationMs: 12, stdout: '\0'.repeat(96 * 1024), stderr: '\0'.repeat(96 * 1024), stdoutTruncated: false, stderrTruncated: false }) const escapedResult = await provider.callTool( 'process_execute', { command: 'large escaped output' }, new AbortController().signal, executeContext ) expect(escapedResult.contextBytes).toBeLessThanOrEqual(256 * 1024) expect(escapedResult.parts[0]).toMatchObject({ type: 'text', text: expect.stringContaining('"stdoutTruncated":true') }) await expect( provider.callTool( 'process_execute', { command: 'exit 0' }, new AbortController().signal, { ...executeContext, workMode: 'ask' } ) ).rejects.toThrow('Ask 模式') await provider.releaseConversation('process-execute') expect(processService.releaseConversation).toHaveBeenCalledWith( 'process-execute' ) await provider.dispose() expect(processService.dispose).toHaveBeenCalledOnce() }) it.each(['process', 'subagent'] as const)('exposes %s output reads in Ask and Execute independently of shell availability and delegation depth', async (source) => { const workspace = await createWorkspace() const scheduler = new SubagentScheduler({ concurrency: 1, queueLimit: 1, timeoutMs: 10000 }) const programming = source === 'process' ? { processService: new LocalDirectModelProcessService({ executableExists: async () => false }) } : { subagentService: new DirectModelSubagentService({ scheduler, runChild: async () => {}, releaseConversation: async () => {} }) } const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, programming) const unconfigured = new ModelToolProvider(workspace) const signal = new AbortController().signal try { for (const workMode of ['ask', 'execute'] as const) { const context = { ...toolContext, runtimeTarget: 'model' as const, workMode, delegationDepth: 1 as const } const tools = await provider.listTools(context, signal) const tool = tools.find((item) => item.name === 'output_read')! expect(tool).toMatchObject({ source: 'builtin', inputSchema: { type: 'object', additionalProperties: false, required: ['handle'], properties: { cursor: { type: 'integer', minimum: 0, default: 0 }, limitBytes: { type: 'integer', minimum: 1, maximum: 32768, default: 32768 } } } }) expect(provider.getApproval(tool, { handle: 'process:test' }, 'read', context)).toMatchObject({ scopeKey: 'model:builtin:output_read', description: expect.stringContaining('当前会话') }) expect(tools.some((item) => item.name === 'process_execute')).toBe(false) expect((await unconfigured.listTools(context, signal)).some((item) => item.name === 'output_read')).toBe(false) await expect(unconfigured.callTool('output_read', { handle: 'process:test' }, signal, context)).rejects.toThrow('服务不可用') } expect((await provider.listTools(toolContext, signal)).some((item) => item.name === 'output_read')).toBe(false) await expect(provider.callTool('output_read', { handle: 'process:test' }, signal, toolContext)).rejects.toThrow('不允许') } finally { await provider.dispose() await unconfigured.dispose() scheduler.dispose() } }) it('preserves complete escaped process and Subagent output through provider pagination and owner cleanup', async () => { const workspace = await createWorkspace() const processService = new LocalDirectModelProcessService() const scheduler = new SubagentScheduler({ concurrency: 1, queueLimit: 2, timeoutMs: 10000 }) const fullStdout = '\0'.repeat(110000) + '\u4f60\ud83d\ude00\ufffdstdout-tail' const fullStderr = 'y'.repeat(110000) + 'stderr-tail' const fullSubagent = '\u4f60'.repeat(70000) + 'subagent-tail' const subagentService = new DirectModelSubagentService({ scheduler, runChild: async (input) => { input.onOutput(fullSubagent) }, releaseConversation: async (conversationId) => { await provider.releaseConversation(conversationId) } }) const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, { processService, subagentService }) const context = { ...toolContext, runtimeTarget: 'model' as const, requestId: 'paged-provider-request', executionSpaceIdentity: (await new LocalWorkspaceAccess(workspace).getIdentity()).id, subagentBridge: { requestContext: { emitEvent: vi.fn() } } } const askContext = { ...context, workMode: 'ask' as const } const signal = new AbortController().signal const parse = (result: ModelToolResult) => { expect(result.contextBytes).toBeLessThan(256 * 1024) const part = result.parts[0]! expect(part.type).toBe('text') if (part.type !== 'text') throw new Error('Expected JSON result') return JSON.parse(part.text) } try { await writeFile(join(workspace, 'output-pages.cjs'), `process.stdout.write(${JSON.stringify(fullStdout)}); process.stderr.write(${JSON.stringify(fullStderr)})`) const processResult = parse(await provider.callTool('process_execute', { command: 'node output-pages.cjs' }, signal, context)) as ProcessExecuteResult expect(processResult.exitCode).toBe(0) const subagentResult = parse(await provider.callTool('subagent_delegate', { task: 'Return the fixture output' }, signal, askContext)) as DirectModelSubagentResult expect(subagentResult.status).toBe('completed') for (const [preview, reference, expected] of [ [processResult.stdout, processResult.stdoutReference!, fullStdout], [processResult.stderr, processResult.stderrReference!, fullStderr], [subagentResult.output, subagentResult.outputReference!, fullSubagent] ] as const) { expect(reference).toBeDefined() let output = preview let cursor = reference.nextCursor while (cursor < reference.totalBytes) { const page = parse(await provider.callTool('output_read', { handle: reference.handle, cursor }, signal, askContext)) as PagedOutputPage expect(page.nextCursor).toBeGreaterThan(cursor) output += page.content cursor = page.nextCursor expect(page.eof).toBe(cursor === reference.totalBytes) } expect(output).toBe(expected) const first = parse(await provider.callTool('output_read', { handle: reference.handle, limitBytes: 1 }, signal, context)) as PagedOutputPage expect(first.cursor).toBe(0) expect(first.nextCursor).toBeGreaterThan(0) await expect(provider.callTool('output_read', { handle: reference.handle }, signal, { ...askContext, conversationId: 'other-owner' })).rejects.toThrow('不属于') } const handle = processResult.stdoutReference!.handle for (const args of [ { handle, cursor: -1 }, { handle, cursor: 0.5 }, { handle, limitBytes: 0 }, { handle, limitBytes: 32769 }, { handle, extra: true }, { handle: '' }, { handle: 'unknown:test' } ]) { await expect(provider.callTool('output_read', args, signal, context)).rejects.toThrow() } await expect(provider.callTool('output_read', { handle }, AbortSignal.abort(), context)).rejects.toThrow() await provider.releaseConversation(context.conversationId) for (const reference of [processResult.stdoutReference!, processResult.stderrReference!, subagentResult.outputReference!]) { await expect(provider.callTool('output_read', { handle: reference.handle }, signal, askContext)).rejects.toThrow('不存在') } } finally { await provider.dispose() scheduler.dispose() } }) it('reserves the output_read slot when loading a full MCP catalog', async () => { const workspace = await createWorkspace() const processService = new LocalDirectModelProcessService() const provider = new ModelToolProvider(workspace, [createMcpServer()], undefined, undefined, false, { processService }) mocks.client.listTools.mockResolvedValueOnce({ tools: Array.from({ length: 96 }, (_, index) => ({ name: `remote_${index}`, inputSchema: { type: 'object', properties: {} } })) }) try { const tools = await provider.listTools({ ...toolContext, runtimeTarget: 'model' }, new AbortController().signal) expect(tools).toHaveLength(100) expect(tools.filter((tool) => tool.name === 'output_read')).toHaveLength(1) } finally { await provider.dispose() } }) it('refreshes a dynamic MCP change announced during initial listing', async () => { const workspace = await createWorkspace() let resolveInitialList: | ((value: { tools: Array<{ name: string inputSchema: { type: 'object' properties: Record } }> }) => void) | undefined mocks.client.getServerCapabilities.mockReturnValue({ tools: { listChanged: true } }) mocks.client.listTools .mockImplementationOnce( () => new Promise((resolve) => { resolveInitialList = resolve }) ) .mockResolvedValueOnce({ tools: [ { name: 'current', inputSchema: { type: 'object', properties: {} } } ] }) const provider = new ModelToolProvider(workspace, [ createMcpServer(true) ]) const listing = provider.listTools( toolContext, new AbortController().signal ) await vi.waitFor(() => { expect(mocks.client.listTools).toHaveBeenCalledOnce() }) const options = mocks.Client.mock.calls[0]?.[1] as | { listChanged?: { tools?: { onChanged?: (error?: Error) => void } } } | undefined options?.listChanged?.tools?.onChanged?.() resolveInitialList?.({ tools: [ { name: 'stale', inputSchema: { type: 'object', properties: {} } } ] }) await expect(listing).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ displayName: 'Search MCP / current' }) ]) ) expect(mocks.client.listTools).toHaveBeenCalledTimes(2) await provider.dispose() }) it('rejects workspace traversal before accessing the filesystem', async () => { const workspace = await createWorkspace() const provider = new ModelToolProvider(workspace) await expect( provider.callTool( 'workspace_read_text', { path: '../outside.txt' }, new AbortController().signal, toolContext ) ).rejects.toThrow('不能超出工作区') }) it('delegates browser tools with per-call conversation context', async () => { const workspace = await createWorkspace() const browserService = createBrowserService() const provider = new ModelToolProvider(workspace, [], browserService) const firstContext = { conversationId: 'browser-conversation-one', browserTabId: firstBrowserTabId, workMode: 'execute' } satisfies ModelToolCallContext const secondContext = { conversationId: 'browser-conversation-two', browserTabId: secondBrowserTabId, workMode: 'execute' } satisfies ModelToolCallContext const signal = new AbortController().signal const readOnlyContext = { conversationId: 'browser-ask', workMode: 'ask' } satisfies ModelToolCallContext await expect( provider.listTools(readOnlyContext, signal) ).resolves.not.toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'browser_screenshot' }) ]) ) await expect( provider.callTool( 'browser_screenshot', {}, signal, readOnlyContext ) ).rejects.toThrow('未知工具') expect(browserService.screenshot).not.toHaveBeenCalled() const tools = await provider.listTools(firstContext, signal) expect( tools.filter((tool) => tool.name.startsWith('browser_')) ).toHaveLength(7) const navigate = tools.find((tool) => tool.name === 'browser_navigate') expect( provider.getApproval( navigate!, { url: 'https://example.com/path?secret=value' }, 'runtime summary', firstContext ) ).toMatchObject({ scopeKey: 'model:browser:navigate:https://example.com', argumentSummary: 'https://example.com/path?[查询参数已隐藏]', allowPermanent: false }) await expect( provider.callTool('browser_screenshot', {}, signal, firstContext) ).resolves.toEqual({ parts: [{ type: 'image', mimeType: 'image/jpeg', data: jpeg }], contextBytes: Buffer.byteLength(jpeg) }) await provider.callTool('browser_screenshot', {}, signal, secondContext) expect(browserService.screenshot).toHaveBeenNthCalledWith( 1, firstContext.conversationId, signal, firstBrowserTabId ) expect(browserService.screenshot).toHaveBeenNthCalledWith( 2, secondContext.conversationId, signal, secondBrowserTabId ) await provider.releaseConversation(firstContext.conversationId) expect(browserService.releaseConversation).toHaveBeenCalledWith( firstContext.conversationId ) expect(browserService.releaseConversation).not.toHaveBeenCalledWith( secondContext.conversationId ) }) it('marks stale browser references as recoverable model tool errors', async () => { const workspace = await createWorkspace() const browserService = createBrowserService() vi.mocked(browserService.click).mockRejectedValue( new BrowserStaleReferenceError() ) const provider = new ModelToolProvider(workspace, [], browserService) await expect( provider.callTool( 'browser_click', { ref: 'b_currentReference' }, new AbortController().signal, toolContext ) ).rejects.toMatchObject({ name: 'RecoverableModelToolError', message: '浏览器元素引用已失效,请重新获取快照', nextAction: expect.stringContaining('browser_snapshot') }) }) it('recovers a closed direct-model tab across calls without cancelling the request', async () => { const workspace = await createWorkspace() await writeFile(join(workspace, 'note.txt'), 'request still active') const service = new BrowserService() const original = service.reserveRequestTab('browser-owner', 'request') const provider = new ModelToolProvider(workspace, [], service) const context = { ...toolContext, requestId: 'request', browserConversationId: 'browser-owner', browserTabId: original.tabId } const controller = new AbortController() const navigate = vi.spyOn(service, 'navigate').mockImplementation( async (_conversationId, _url, signal) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => reject(signal.reason), { once: true }) }) ) const snapshot = vi.spyOn(service, 'snapshot').mockResolvedValue({} as never) const createTab = vi.spyOn(service, 'createTab').mockImplementation(async () => { const replacement = service.reserveRequestTab('browser-owner', 'replacement') return { tabId: replacement.tabId } as never }) const acquire = vi.spyOn(service, 'acquireTabUsage') try { const pending = provider.callTool('browser_navigate', { url: 'https://example.com/' }, controller.signal, context) const closed = expect(pending).rejects.toMatchObject({ name: 'RecoverableModelToolError', nextAction: expect.stringContaining('browser_navigate') }) await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce()) await service.closeTab('browser-owner', original.tabId) await closed expect(controller.signal.aborted).toBe(false) for (const [name, args] of [ ['browser_snapshot', {}], ['browser_click', { ref: 'b_old' }], ['browser_type', { ref: 'b_old', text: 'value' }], ['browser_select', { ref: 'b_old', value: 'value' }], ['browser_back', {}], ['browser_screenshot', {}] ] as const) { await expect(provider.callTool(name, args, controller.signal, context)).rejects.toMatchObject({ name: 'RecoverableModelToolError', nextAction: expect.stringContaining('browser_navigate') }) } expect(createTab).not.toHaveBeenCalled() expect(snapshot).not.toHaveBeenCalled() await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, controller.signal, context)) .resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('request still active') }] }) navigate.mockResolvedValue({ url: 'https://example.com/', origin: 'https://example.com' }) await provider.callTool('browser_navigate', { url: 'https://example.com/' }, controller.signal, context) const replacementTabId = navigate.mock.calls[1]![3]! expect(replacementTabId).not.toBe(original.tabId) expect(createTab).toHaveBeenCalledWith('browser-owner', undefined, controller.signal, expect.stringContaining(`:${original.tabId}`)) expect(acquire).toHaveBeenCalledWith('browser-owner', replacementTabId, expect.any(String)) await provider.listTools(context, controller.signal) await provider.callTool('browser_snapshot', {}, controller.signal, context) expect(snapshot).toHaveBeenLastCalledWith('browser-owner', expect.any(AbortSignal), replacementTabId) await expect(provider.callTool('browser_snapshot', {}, controller.signal, { ...context, requestId: 'other-request' })) .rejects.toMatchObject({ name: 'RecoverableModelToolError' }) expect(createTab).toHaveBeenCalledOnce() expect(controller.signal.aborted).toBe(false) } finally { original.release() await provider.dispose() await service.dispose() } }) it('keeps a request on its bound tab when another tab changes or closes', async () => { const workspace = await createWorkspace() const browserService = createBrowserService() const provider = new ModelToolProvider(workspace, [], browserService) const conversationId = 'two-tab-conversation' const firstRequest = { conversationId, browserTabId: firstBrowserTabId, workMode: 'execute', requestId: 'first-tab-request' } satisfies ModelToolCallContext const secondRequest = { conversationId, browserTabId: secondBrowserTabId, workMode: 'execute', requestId: 'second-tab-request' } satisfies ModelToolCallContext const signal = new AbortController().signal await provider.callTool('browser_snapshot', {}, signal, firstRequest) await provider.callTool('browser_snapshot', {}, signal, secondRequest) expect(browserService.snapshot).toHaveBeenNthCalledWith( 1, conversationId, signal, firstBrowserTabId ) expect(browserService.snapshot).toHaveBeenNthCalledWith( 2, conversationId, signal, secondBrowserTabId ) vi.mocked(browserService.click).mockImplementation( async (_conversationId, _ref, _signal, tabId) => { if (tabId === firstBrowserTabId) { throw new Error('浏览器标签页不存在或不属于当前对话') } } ) await expect( provider.callTool( 'browser_click', { ref: 'b_closedTabReference' }, signal, firstRequest ) ).rejects.toThrow('浏览器标签页不存在或不属于当前对话') expect(browserService.click).toHaveBeenCalledWith( conversationId, 'b_closedTabReference', signal, firstBrowserTabId ) }) it('exposes only allowlisted read-only Exa tools in Ask and Execute', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ tools: [ { name: 'web_search_exa', inputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false } }, { name: 'web_fetch_exa', inputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false } }, { name: 'future_untrusted_tool', inputSchema: { type: 'object' }, annotations: { readOnlyHint: false } } ] }) const provider = new ModelToolProvider( workspace, [], undefined, undefined, true ) const signal = new AbortController().signal const askContext = { conversationId: 'web-search-ask', workMode: 'ask' } satisfies ModelToolCallContext await expect(provider.listTools(askContext, signal)).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'web_search', displayName: '联网搜索', source: 'builtin' }), expect.objectContaining({ name: 'web_fetch', displayName: '读取网页', source: 'builtin' }) ]) ) await provider.callTool( 'web_search', { query: 'GoodBuddy current release', numResults: 3 }, signal, askContext ) expect(mocks.client.callTool).toHaveBeenCalledWith( { name: 'web_search_exa', arguments: { query: 'GoodBuddy current release', numResults: 3 } }, undefined, expect.objectContaining({ signal }) ) await provider.callTool( 'web_fetch', { urls: ['https://example.com/article'], maxCharacters: 2_000 }, signal, { ...askContext, workMode: 'execute' } ) expect(mocks.client.callTool).toHaveBeenLastCalledWith( { name: 'web_fetch_exa', arguments: { urls: ['https://example.com/article'], maxCharacters: 2_000 } }, undefined, expect.objectContaining({ signal }) ) await expect( provider.callTool( 'web_fetch', { urls: ['http://localhost/private'] }, signal, askContext ) ).rejects.toThrow('公开 HTTP(S) URL') }) it('fails closed when an Exa search tool is not marked read-only', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ tools: [ { name: 'web_search_exa', inputSchema: { type: 'object' }, annotations: { readOnlyHint: false, destructiveHint: false } }, { name: 'web_fetch_exa', inputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false } } ] }) const provider = new ModelToolProvider( workspace, [], undefined, undefined, true ) await expect( provider.callTool( 'web_search', { query: 'test', numResults: 1 }, new AbortController().signal, { conversationId: 'web-search-invalid', workMode: 'ask' } ) ).rejects.toMatchObject({ name: 'RecoverableModelToolError', message: '联网搜索暂时不可用' }) expect(mocks.client.close).toHaveBeenCalledOnce() }) it('loads and invokes configured MCP tools through provider-safe names', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ tools: [ { name: 'search-web', description: 'Search', inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } ] }) const provider = new ModelToolProvider(workspace, [createMcpServer()]) const signal = new AbortController().signal const tools = await provider.listTools(toolContext, signal) const mcpTool = tools.find((tool) => tool.source === 'mcp') expect(mcpTool).toMatchObject({ displayName: 'Search MCP / search-web', source: 'mcp' }) expect(mcpTool?.name).toMatch(/^mcp_[a-f0-9]{8}_[a-f0-9]{8}_/u) await expect( provider.callTool( mcpTool?.name ?? '', { query: 'GoodBuddy' }, signal, toolContext ) ).resolves.toEqual({ parts: [{ type: 'text', text: 'MCP result' }], contextBytes: 10 }) expect(mocks.client.callTool).toHaveBeenCalledWith( { name: 'search-web', arguments: { query: 'GoodBuddy' } }, undefined, expect.objectContaining({ timeout: 30_000, signal, resetTimeoutOnProgress: true, maxTotalTimeout: 300_000, onprogress: expect.any(Function) }) ) await provider.dispose() expect(mocks.client.close).toHaveBeenCalledOnce() }) it('refreshes opted-in dynamic MCP tools between model rounds', async () => { const workspace = await createWorkspace() mocks.client.getServerCapabilities.mockReturnValue({ tools: { listChanged: true } }) mocks.client.listTools .mockResolvedValueOnce({ tools: [ { name: 'crmtools_load_tools', inputSchema: { type: 'object', properties: { groups: { type: 'array', items: { type: 'string' } } }, required: ['groups'] } } ] }) .mockResolvedValueOnce({ tools: [ { name: 'crmtools_load_tools', inputSchema: { type: 'object', properties: { groups: { type: 'array', items: { type: 'string' } } }, required: ['groups'] } }, { name: 'crmtools_list_opportunities', inputSchema: { type: 'object' } } ] }) const provider = new ModelToolProvider( workspace, [createMcpServer(true)] ) const signal = new AbortController().signal const initialTools = await provider.listTools(toolContext, signal) const loadTool = initialTools.find( (tool) => tool.displayName === 'Search MCP / crmtools_load_tools' ) expect(loadTool).toBeDefined() const clientOptions = mocks.Client.mock.calls[0]?.[1] as | { listChanged: { tools: { onChanged: ( error: Error | null, tools: unknown[] | null ) => void } } } | undefined expect(clientOptions).toBeDefined() if (!clientOptions) { throw new Error('Expected dynamic MCP client options') } clientOptions.listChanged.tools.onChanged(null, null) await provider.callTool( loadTool?.name ?? '', { groups: ['opportunity'] }, signal, toolContext ) const refreshedTools = await provider.listTools( toolContext, signal ) expect(mocks.Client).toHaveBeenCalledWith( { name: 'goodbuddy-direct-model', version: '0.1.0' }, expect.objectContaining({ listChanged: { tools: expect.objectContaining({ autoRefresh: false, debounceMs: 0, onChanged: expect.any(Function) }) } }) ) expect(mocks.client.listTools).toHaveBeenCalledTimes(2) expect(refreshedTools).toEqual( expect.arrayContaining([ expect.objectContaining({ displayName: 'Search MCP / crmtools_list_opportunities' }) ]) ) }) it('preserves ordered bounded MCP text, image, and unsupported audio parts', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ tools: [ { name: 'capture', inputSchema: { type: 'object' } } ] }) mocks.client.callTool.mockResolvedValue({ content: [ { type: 'text', text: 'before' }, { type: 'image', mimeType: 'image/png', data: png }, { type: 'audio', mimeType: 'audio/wav', data: 'ignored' }, { type: 'text', text: 'after' } ] }) const provider = new ModelToolProvider(workspace, [createMcpServer()]) const tools = await provider.listTools(toolContext, new AbortController().signal) const tool = tools.find((candidate) => candidate.source === 'mcp') await expect( provider.callTool( tool?.name ?? '', {}, new AbortController().signal, toolContext ) ).resolves.toEqual({ parts: [ { type: 'text', text: 'before' }, { type: 'image', mimeType: 'image/png', data: png }, { type: 'text', text: '[audio result unsupported]' }, { type: 'text', text: 'after' } ], contextBytes: Buffer.byteLength('before') + Buffer.byteLength(png) + Buffer.byteLength('[audio result unsupported]') + Buffer.byteLength('after') }) }) it.each([ { mimeType: 'image/jpeg', data: Buffer.from([0xff, 0xd8, 0xff]).toString('base64') }, { mimeType: 'image/webp', data: Buffer.from([ 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50 ]).toString('base64') } ])('accepts a valid $mimeType signature', async ({ mimeType, data }) => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'capture', inputSchema: { type: 'object' } }] }) mocks.client.callTool.mockResolvedValue({ content: [{ type: 'image', mimeType, data }] }) const provider = new ModelToolProvider(workspace, [createMcpServer()]) const tools = await provider.listTools(toolContext, new AbortController().signal) const tool = tools.find((candidate) => candidate.source === 'mcp') await expect( provider.callTool( tool?.name ?? '', {}, new AbortController().signal, toolContext ) ).resolves.toEqual({ parts: [{ type: 'image', mimeType, data }], contextBytes: Buffer.byteLength(data) }) }) it.each([ { name: 'malformed base64', image: { type: 'image', mimeType: 'image/png', data: `${png.slice(0, -1)}!` }, message: '无效的 base64' }, { name: 'MIME signature mismatch', image: { type: 'image', mimeType: 'image/jpeg', data: png }, message: 'MIME 类型与文件签名不匹配' }, { name: 'unsupported MIME type', image: { type: 'image', mimeType: 'image/gif', data: png }, message: '不支持的图片格式' } ])('rejects $name in MCP image blocks', async ({ image, message }) => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'capture', inputSchema: { type: 'object' } }] }) mocks.client.callTool.mockResolvedValue({ content: [image] }) const provider = new ModelToolProvider(workspace, [createMcpServer()]) const tools = await provider.listTools(toolContext, new AbortController().signal) const tool = tools.find((candidate) => candidate.source === 'mcp') await expect( provider.callTool( tool?.name ?? '', {}, new AbortController().signal, toolContext ) ).rejects.toThrow(message) }) it.each([false, true])('preserves bounded native MCP error text (wrapped: %s)', async (wrapped) => { mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'read', inputSchema: { type: 'object' } }] }) const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer()]) const signal = new AbortController().signal try { const tools = await provider.listTools(toolContext, signal) const name = tools.find((tool) => tool.source === 'mcp')!.name for (const detail of ['File not found: note.md', '\u4e2d'.repeat(100_000), '']) { const result = { isError: true, content: [ { type: 'text', text: detail }, { type: 'text', text: 'Use list_directory.' } ] } mocks.client.callTool.mockResolvedValue(wrapped ? { toolResult: result } : result) const error = await provider.callTool(name, {}, signal, toolContext).catch((reason: unknown) => reason) expect(error).toBeInstanceOf(Error) const message = (error as Error).message expect(message).toContain('MCP Server') expect(Buffer.byteLength(message)).toBeLessThanOrEqual(256 * 1024) if (Buffer.byteLength(detail) > 256 * 1024) { expect(message).toContain(detail.slice(0, 100)) expect(message).toContain('[GoodBuddy result truncated]') expect(message).not.toContain('\ufffd') } else { expect(message).toContain(`${detail}\nUse list_directory.`) } } mocks.client.callTool.mockResolvedValue({ isError: true, content: [] }) await expect(provider.callTool(name, {}, signal, toolContext)).rejects.toThrow('MCP Server') } finally { await provider.dispose() } }) it('counts encoded and decoded image data against the MCP result budget', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'capture', inputSchema: { type: 'object' } }] }) const encodedContextOversizedPng = Buffer.concat([ Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]), Buffer.alloc(200 * 1024) ]).toString('base64') mocks.client.callTool.mockResolvedValue({ content: [ { type: 'image', mimeType: 'image/png', data: encodedContextOversizedPng } ] }) const provider = new ModelToolProvider(workspace, [createMcpServer()]) const tools = await provider.listTools(toolContext, new AbortController().signal) const tool = tools.find((candidate) => candidate.source === 'mcp') await expect( provider.callTool( tool?.name ?? '', {}, new AbortController().signal, toolContext ) ).rejects.toThrow('工具结果超过 256KB') const decodedOversizedPng = Buffer.concat([ Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]), Buffer.alloc(256 * 1024) ]).toString('base64') mocks.client.callTool.mockResolvedValue({ content: [ { type: 'image', mimeType: 'image/png', data: decodedOversizedPng } ] }) await expect( provider.callTool( tool?.name ?? '', {}, new AbortController().signal, toolContext ) ).rejects.toThrow('过大的 base64 图片') }) it('bounds MCP content block and image counts', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'capture', inputSchema: { type: 'object' } }] }) const provider = new ModelToolProvider(workspace, [createMcpServer()]) const tools = await provider.listTools(toolContext, new AbortController().signal) const tool = tools.find((candidate) => candidate.source === 'mcp') mocks.client.callTool.mockResolvedValue({ content: Array.from({ length: 101 }, () => ({ type: 'text', text: 'x' })) }) await expect( provider.callTool( tool?.name ?? '', {}, new AbortController().signal, toolContext ) ).rejects.toThrow('内容块数量超过安全限制') mocks.client.callTool.mockResolvedValue({ content: Array.from({ length: 9 }, () => ({ type: 'image', mimeType: 'image/png', data: png })) }) await expect( provider.callTool( tool?.name ?? '', {}, new AbortController().signal, toolContext ) ).rejects.toThrow('图片数量超过安全限制') }) it('streams required task tools and best-effort cancels their MCP task', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ tools: [ { name: 'long-job', inputSchema: { type: 'object' }, execution: { taskSupport: 'required' } } ] }) const controller = new AbortController() mocks.tasks.callToolStream.mockImplementation(async function* ( _params, _schema, options ) { yield { type: 'taskCreated', task: { taskId: 'task-1', status: 'working' } } controller.abort() throw options.signal.reason }) const provider = new ModelToolProvider(workspace, [createMcpServer()]) const tools = await provider.listTools(toolContext, controller.signal) const tool = tools.find((candidate) => candidate.source === 'mcp') expect(tool?.taskSupport).toBe('required') await expect( provider.callTool( tool?.name ?? '', {}, controller.signal, toolContext ) ).rejects.toThrow() expect(mocks.tasks.callToolStream).toHaveBeenCalledWith( { name: 'long-job', arguments: {} }, undefined, expect.objectContaining({ timeout: 30_000, signal: controller.signal, resetTimeoutOnProgress: true, maxTotalTimeout: 300_000 }) ) expect(mocks.tasks.cancelTask).toHaveBeenCalledWith( 'task-1', { timeout: 5_000, maxTotalTimeout: 5_000 } ) }) })