Connect Dify, FastGPT and RAGFlow through read-only bindings, encrypted local credentials, shared retrieval and persistent citations. Configuration and removal affect GoodBuddy only. Show active conversations across projects, retain older live work, and improve project dialog scrolling. Queue concurrent questions and route managed SSH OpenCode questions through the Agent. Preserve recoverable read/search errors and partial search results. Document the Electron UI automation standard, real model and external knowledge validation, and remaining acceptance limits. Validation: earlier full regression passed 4081 tests with 66 skipped; subsequent focused remote-question checks and real Linux evidence are recorded in feature progress. Precommit typecheck and lint passed; latest project dialog and shared UI suites passed 58 tests. The full suite was not rerun for the final dialog changes. Release note: Use external knowledge in conversations, find active work across projects, and answer concurrent local or managed SSH OpenCode questions. Managed SSH questions require the updated Agent adapter.
2319 lines
76 KiB
TypeScript
2319 lines
76 KiB
TypeScript
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 {
|
|
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<string> {
|
|
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',
|
|
{ path: 'docs', filesOnly: true },
|
|
signal,
|
|
toolContext
|
|
)
|
|
expect(listing.parts).toEqual([
|
|
expect.objectContaining({
|
|
type: 'text',
|
|
text: expect.stringContaining('docs/note.txt')
|
|
})
|
|
])
|
|
const matches = await provider.callTool(
|
|
'workspace_rg',
|
|
{ path: 'docs', pattern: 'target' },
|
|
signal,
|
|
toolContext
|
|
)
|
|
expect(matches.parts).toEqual([
|
|
expect.objectContaining({
|
|
type: 'text',
|
|
text: expect.stringContaining('docs/search.txt:1:2:中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 [
|
|
{ pattern: 'x', path: workspace }, { pattern: 'x', path: 'missing' },
|
|
{ pattern: '[' }, { pattern: 'x', glob: ['['] }, { maxResults: 0 }
|
|
]) {
|
|
await expect(provider.callTool('workspace_rg', args, signal, toolContext)).rejects.toMatchObject({
|
|
name: 'RecoverableModelToolError',
|
|
nextAction: expect.stringContaining('adjust the search path/globs')
|
|
})
|
|
}
|
|
await expect(provider.callTool('workspace_rg', { pattern: '[', fixedStrings: true }, signal, toolContext))
|
|
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('note.txt:1:1:') }] })
|
|
} 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('recovers the wrapped rg exit code 2 but leaves a missing executable fatal', async () => {
|
|
const workspace = await createWorkspace()
|
|
const access = new LocalWorkspaceAccess(workspace)
|
|
const failure = new Error('regex parse error', {
|
|
cause: Object.assign(new Error('process failed'), { code: 2 })
|
|
})
|
|
vi.spyOn(access, 'stat').mockRejectedValueOnce(failure)
|
|
const provider = new ModelToolProvider(access, [], undefined, undefined, false, {
|
|
ripgrepExecutablePath: join(workspace, 'missing-rg.exe')
|
|
})
|
|
try {
|
|
await expect(provider.callTool('workspace_rg', { path: 'note.txt', pattern: '[' }, new AbortController().signal, toolContext))
|
|
.rejects.toMatchObject({ name: 'RecoverableModelToolError', cause: failure })
|
|
const error = await provider.callTool('workspace_rg', { pattern: '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 = { path: 'note.txt', ...(name === 'workspace_rg' ? { pattern: 'x' } : {}) }
|
|
try {
|
|
const abortError = new DOMException('cancelled', 'AbortError')
|
|
operation.mockRejectedValueOnce(abortError)
|
|
await expect(provider.callTool(name, args, new AbortController().signal, toolContext)).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, toolContext)).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 all 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(() => [
|
|
'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 context = {
|
|
conversationId: 'knowledge-capacity',
|
|
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(82)
|
|
})
|
|
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(83)
|
|
})
|
|
const overflowingProvider = new ModelToolProvider(
|
|
workspace,
|
|
[createMcpServer()],
|
|
undefined,
|
|
gateway,
|
|
false,
|
|
{ ripgrepExecutablePath: rgPath }
|
|
)
|
|
await expect(
|
|
overflowingProvider.listTools(
|
|
context,
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toHaveLength(18)
|
|
await overflowingProvider.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<ModelSubagentRequestContext>({
|
|
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<ModelSubagentRequestContext>({
|
|
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<string, never>
|
|
}
|
|
}>
|
|
}) => 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<never>((_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('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
|
|
}
|
|
)
|
|
})
|
|
})
|