Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.
- Remove the Main readWithFallback path and the supervision worker's
separate business write connection; manifests are scanned read-only and
committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
directories and reclaim confirmed stale ones; stop writing duplicate
parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
acceptance fixtures, and maintenance/architecture documentation.
Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2438 lines
86 KiB
TypeScript
2438 lines
86 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 { 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'
|
|
import { FileOutputBacking } from '../../../tests/support/paged-output-backing'
|
|
|
|
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,
|
|
} 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',
|
|
{ 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'])('keeps %s cancellation fatal', async (name) => {
|
|
const access = new LocalWorkspaceAccess(await createWorkspace())
|
|
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
|
|
const operation = vi.spyOn(access, 'readText')
|
|
const args = { path: 'note.txt' }
|
|
const context = toolContext
|
|
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 the local model runtime', 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, 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('rejects calls to capabilities absent from the request without a mode', async () => {
|
|
const provider = new ModelToolProvider(await createWorkspace())
|
|
const context = { conversationId: 'unbound-tools' }
|
|
const signal = new AbortController().signal
|
|
try {
|
|
for (const name of ['browser_click', 'note_create', 'goodbuddy_config_apply', 'mcp_spoofed_mutation', 'process_execute', 'generate_image']) {
|
|
await expect(provider.callTool(name, {}, signal, context)).rejects.toThrow()
|
|
}
|
|
expect(mocks.client.callTool).not.toHaveBeenCalled()
|
|
} finally {
|
|
await provider.dispose()
|
|
}
|
|
})
|
|
|
|
it('exposes and calls scoped reads and writes without a mode', 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',
|
|
knowledgeCapabilityToken: 'main-only-token'
|
|
} satisfies ModelToolCallContext
|
|
|
|
const askTools = await provider.listTools(askContext, signal)
|
|
expect(askTools.map((tool) => tool.name)).toEqual(expect.arrayContaining([
|
|
'workspace_read_text',
|
|
'workspace_apply_patch',
|
|
'knowledge_list',
|
|
'knowledge_search',
|
|
'note_list',
|
|
'note_get',
|
|
'note_search',
|
|
'goodbuddy_config_capabilities',
|
|
'goodbuddy_config_get',
|
|
'goodbuddy_config_plan',
|
|
'goodbuddy_config_apply',
|
|
'note_create'
|
|
]))
|
|
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',
|
|
},
|
|
signal
|
|
)
|
|
).resolves.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ name: 'workspace_read_text' })
|
|
])
|
|
)
|
|
const executeTools = await provider.listTools(
|
|
askContext,
|
|
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
|
|
)
|
|
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
|
|
)
|
|
).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
|
|
)
|
|
).toMatchObject({
|
|
scopeKey: 'model:goodbuddy-config:apply',
|
|
allowPermanent: false,
|
|
description: '该操作会按计划修改 GoodBuddy 应用偏好或扩展能力。'
|
|
})
|
|
})
|
|
|
|
it('awaits asynchronous scoped gateway results before serializing tool output', async () => {
|
|
const note = { id: 'note', title: 'Stored note' }
|
|
const gateway = {
|
|
listLibraries: async () => [{ id: 'library' }],
|
|
listMagicNotes: async () => [note],
|
|
searchMagicNotes: async () => [note],
|
|
getMagicNote: async () => note,
|
|
createMagicNote: async () => note,
|
|
updateMagicNote: async () => note,
|
|
createMagicNoteEntry: async () => note,
|
|
updateMagicNoteEntry: async () => note,
|
|
deleteMagicNoteEntry: async () => note,
|
|
deleteMagicNote: async () => ({ deleted: true })
|
|
} as unknown as KnowledgeMcpGateway
|
|
const provider = new ModelToolProvider(await createWorkspace(), [], undefined, gateway)
|
|
try {
|
|
for (const [name, expected] of [
|
|
['knowledge_list', { libraries: [{ id: 'library' }] }],
|
|
['note_list', { notes: [note] }], ['note_search', { notes: [note] }],
|
|
...['note_get', 'note_create', 'note_update', 'note_entry_create', 'note_entry_update', 'note_entry_delete']
|
|
.map(name => [name, { note }] as const),
|
|
['note_delete', { deleted: true }]
|
|
] as const) {
|
|
const result = await provider.callTool(name, {}, new AbortController().signal,
|
|
{ ...toolContext, knowledgeCapabilityToken: 'token' })
|
|
expect(result.parts).toEqual([{ type: 'text', text: JSON.stringify(expected) }])
|
|
}
|
|
} finally { await provider.dispose() }
|
|
})
|
|
|
|
it.each(['release', 'dispose'] as const)('waits for output adoption before %s drops the call reference', async (cleanup) => {
|
|
const workspace = new LocalWorkspaceAccess(await createWorkspace())
|
|
const service = new LocalDirectModelProcessService()
|
|
const reference = { handle: 'process:retained', totalBytes: 100000, nextCursor: 100 }
|
|
vi.spyOn(service, 'execute').mockResolvedValue({
|
|
shell: { kind: 'powershell', label: 'pwsh' }, cwd: '.', exitCode: 0, durationMs: 1,
|
|
stdout: 'preview', stderr: '', stdoutTruncated: true, stderrTruncated: false, stdoutReference: reference
|
|
})
|
|
const release = vi.spyOn(service, 'releaseConversation')
|
|
const dispose = vi.spyOn(service, 'dispose')
|
|
let finishAdoption!: () => void
|
|
let adoptionStarted!: () => void
|
|
const started = new Promise<void>(resolve => { adoptionStarted = resolve })
|
|
const pending = new Promise<void>(resolve => { finishAdoption = resolve })
|
|
const outputAdopt = vi.fn(async () => { adoptionStarted(); await pending; return 'resource' })
|
|
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false,
|
|
{ processService: service, outputAdopt })
|
|
try {
|
|
const result = provider.callTool('process_execute', { command: 'fixture' }, new AbortController().signal, {
|
|
conversationId: 'owner', runtimeTarget: 'model',
|
|
outputHistory: { conversationId: 'parent', messageId: 'message' }
|
|
})
|
|
await started
|
|
const closing = cleanup === 'release' ? provider.releaseConversation('owner') : provider.dispose()
|
|
await Promise.resolve()
|
|
expect(release).not.toHaveBeenCalled()
|
|
expect(dispose).not.toHaveBeenCalled()
|
|
finishAdoption()
|
|
expect((await result).outputReferences).toEqual([reference])
|
|
await closing
|
|
expect(outputAdopt).toHaveBeenCalledWith('owner', reference.handle, 'parent', 'message', 'message')
|
|
expect(cleanup === 'release' ? release : dispose).toHaveBeenCalledOnce()
|
|
} finally { finishAdoption(); await provider.dispose() }
|
|
})
|
|
|
|
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(() => []),
|
|
isStoryGraphAvailable: vi.fn(async () => true),
|
|
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
|
|
} as unknown as KnowledgeMcpGateway
|
|
const context = {
|
|
conversationId: 'knowledge-capacity',
|
|
runtimeTarget: 'model',
|
|
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 = {
|
|
isStoryGraphAvailable: vi.fn(async () => true),
|
|
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 = {
|
|
isStoryGraphAvailable: vi.fn(async () => true),
|
|
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 to direct-model requests in the matching space', 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',
|
|
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,
|
|
executionSpaceIdentity: 'different-space'
|
|
},
|
|
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,
|
|
executionSpaceIdentity: 'different-space'
|
|
}
|
|
)
|
|
).rejects.toThrow('执行空间')
|
|
|
|
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 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 {
|
|
const context = { ...toolContext, runtimeTarget: 'model' as const, 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 backingStore = new FileOutputBacking()
|
|
const outputStore = { backingStore }
|
|
const processService = new LocalDirectModelProcessService({ outputStore })
|
|
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>({
|
|
outputStore,
|
|
scheduler,
|
|
runChild: async (input) => { await input.onOutput(fullSubagent) },
|
|
releaseConversation: async (conversationId) => { await provider.releaseConversation(conversationId) }
|
|
})
|
|
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, { processService, subagentService, outputStore })
|
|
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
|
|
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')
|
|
expect(context.subagentBridge.requestContext.emitEvent).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'subagent', outputReference: subagentResult.outputReference
|
|
}))
|
|
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()
|
|
await backingStore.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('lists and calls enabled browser reads and writes without a mode', async () => {
|
|
const service = createBrowserService()
|
|
const provider = new ModelToolProvider(await createWorkspace(), [], service)
|
|
const context = toolContext
|
|
const signal = new AbortController().signal
|
|
const tools = (await provider.listTools(context, signal))
|
|
.filter((tool) => tool.name.startsWith('browser_'))
|
|
expect(tools).toHaveLength(7)
|
|
for (const tool of tools.filter(tool => ['browser_snapshot', 'browser_screenshot'].includes(tool.name))) {
|
|
expect(() => provider.getApproval(tool, {}, '', context)).not.toThrow()
|
|
await provider.callTool(tool.name, {}, signal, context)
|
|
}
|
|
expect(service.snapshot).toHaveBeenCalledWith(context.conversationId, signal, context.browserTabId)
|
|
expect(service.screenshot).toHaveBeenCalledWith(context.conversationId, signal, context.browserTabId)
|
|
for (const [name, args] of [
|
|
['browser_navigate', { url: 'https://example.com' }],
|
|
['browser_click', { ref: 'b_target' }],
|
|
['browser_type', { ref: 'b_target', text: 'hello' }],
|
|
['browser_select', { ref: 'b_target', value: 'one' }],
|
|
['browser_back', {}]
|
|
] as const) {
|
|
await provider.callTool(name, args, signal, context)
|
|
}
|
|
expect(service.navigate).toHaveBeenCalledOnce()
|
|
expect(service.click).toHaveBeenCalledOnce()
|
|
expect(service.type).toHaveBeenCalledOnce()
|
|
expect(service.select).toHaveBeenCalledOnce()
|
|
expect(service.back).toHaveBeenCalledOnce()
|
|
const disabled = new ModelToolProvider(await createWorkspace())
|
|
expect((await disabled.listTools(context, signal)).some((tool) => tool.name.startsWith('browser_'))).toBe(false)
|
|
await expect(disabled.callTool('browser_snapshot', {}, signal, context)).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,
|
|
} satisfies ModelToolCallContext
|
|
const secondContext = {
|
|
conversationId: 'browser-conversation-two',
|
|
browserTabId: secondBrowserTabId,
|
|
} satisfies ModelToolCallContext
|
|
const signal = new AbortController().signal
|
|
|
|
const readOnlyContext = {
|
|
conversationId: 'browser-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,
|
|
requestId: 'first-tab-request'
|
|
} satisfies ModelToolCallContext
|
|
const secondRequest = {
|
|
conversationId,
|
|
browserTabId: secondBrowserTabId,
|
|
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 the builtin Exa search and fetch tools', 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',
|
|
} 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
|
|
)
|
|
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('does not use read-only annotations as an Exa tool permission gate', 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',
|
|
}
|
|
)
|
|
).resolves.toBeDefined()
|
|
expect(mocks.client.callTool).toHaveBeenCalledOnce()
|
|
await provider.dispose()
|
|
})
|
|
|
|
it.each(['disabled', 'unassigned', 'other-runtime'] as const)('does not discover or call %s custom MCP tools', async (state) => {
|
|
const server = createMcpServer()
|
|
if (state === 'disabled') server.enabled = false
|
|
else server.assignments = state === 'unassigned' ? [] : ['opencode']
|
|
const provider = new ModelToolProvider(await createWorkspace(), [server])
|
|
const signal = new AbortController().signal
|
|
try {
|
|
expect((await provider.listTools(toolContext, signal)).some(tool => tool.source === 'mcp')).toBe(false)
|
|
await expect(provider.callTool('mcp_spoofed_mutation', {}, signal, toolContext)).rejects.toThrow('未知工具')
|
|
expect(mocks.Client).not.toHaveBeenCalled()
|
|
expect(mocks.client.callTool).not.toHaveBeenCalled()
|
|
} finally {
|
|
await provider.dispose()
|
|
}
|
|
})
|
|
|
|
it.each([true, false])('honors Harness assignments for custom MCP writes (enabled=%s)', async (enabled) => {
|
|
const server = createMcpServer()
|
|
server.assignments = ['deepseek-harness']
|
|
server.enabled = enabled
|
|
mocks.client.listTools.mockResolvedValue({ tools: [{
|
|
name: 'write_document', inputSchema: { type: 'object' },
|
|
annotations: { readOnlyHint: false, destructiveHint: true }
|
|
}] })
|
|
const workspace = await createWorkspace()
|
|
const provider = new ModelToolProvider(workspace, [server], undefined, undefined, false, {
|
|
runtimeTarget: 'deepseek-harness'
|
|
})
|
|
const modelProvider = new ModelToolProvider(workspace, [server])
|
|
const signal = new AbortController().signal
|
|
try {
|
|
const tools = (await provider.listTools(toolContext, signal)).filter(tool => tool.source === 'mcp')
|
|
expect(tools).toHaveLength(enabled ? 1 : 0)
|
|
expect((await modelProvider.listTools(toolContext, signal)).some(tool => tool.source === 'mcp')).toBe(false)
|
|
if (enabled) {
|
|
await expect(provider.callTool(tools[0]!.name, {}, signal, toolContext)).resolves.toMatchObject({
|
|
parts: [{ type: 'text', text: 'MCP result' }]
|
|
})
|
|
expect(mocks.client.callTool).toHaveBeenCalledOnce()
|
|
await expect(modelProvider.callTool(tools[0]!.name, {}, signal, toolContext)).rejects.toThrow('未知工具')
|
|
} else {
|
|
expect(mocks.Client).not.toHaveBeenCalled()
|
|
expect(mocks.client.callTool).not.toHaveBeenCalled()
|
|
}
|
|
} finally {
|
|
await provider.dispose()
|
|
await modelProvider.dispose()
|
|
}
|
|
})
|
|
|
|
it('loads and invokes configured MCP writes through provider-safe names without a mode', async () => {
|
|
const workspace = await createWorkspace()
|
|
mocks.client.listTools.mockResolvedValue({
|
|
tools: [
|
|
{
|
|
name: 'search-web',
|
|
description: 'Search',
|
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
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
|
|
}
|
|
)
|
|
})
|
|
})
|