fix: complete runtime and workspace workflows
Restore Ask workspace reads and output paging without granting write access, and let programming Subagents reuse their parent's browser page. Allocate browser resources only when used, keep Agent pages aligned with workbar tabs, recover released bindings, and remove unused automatic screenshot work. Preserve inherited Runtime selections during remote usage persistence and manual compression. Keep viable channel model fallbacks, display the actual scheduled-task mode and Harness model source, and avoid rediscovery delays when calling healthy MCP tools. Fix directory/history refresh, case-only renames, rename-aware commit diffs, and FastGPT probe success reporting. Add regression coverage and align feature documentation. The Agent-side connection timeout fix requires deploying an updated Agent; existing Ask read-only and Execute authorization boundaries remain unchanged. Final validation: 3839 tests passed, 66 skipped; typecheck, lint, development build, and diff checks passed. Isolated Electron UI/MCP workflows passed with a loopback model fixture. Current-source Linux x64 Host checks passed with three successful real model requests. Chromium loadURL rejection was not reproduced in the UI; released-binding recovery has automated coverage. Release note: 修复 Ask 读取项目文件、浏览器页面显示与恢复、文件管理及模型配置跟随中的不一致,并减少无用截图和 MCP 等待。定时任务显示实际工作模式,远程会话可正常保存继承 Runtime 的用量;远端连接超时修复需更新 Agent 后生效。
This commit is contained in:
@@ -1116,6 +1116,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' })
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
@@ -1145,6 +1146,8 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' })
|
||||
vi.mocked(provider.listTools).mockRejectedValue(new Error('unrelated server unavailable'))
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
@@ -1158,6 +1161,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
]
|
||||
})
|
||||
expect(authorize).toHaveBeenCalledTimes(1)
|
||||
expect(provider.listTools).toHaveBeenCalledOnce()
|
||||
expect(provider.callTool).toHaveBeenCalledWith(
|
||||
mcpTool().name,
|
||||
{ kind: 'cube' },
|
||||
@@ -1283,6 +1287,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' })
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
@@ -1311,6 +1316,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' })
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/call', {
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
RuntimeAuthorizer,
|
||||
RuntimeEvent
|
||||
} from './runtime'
|
||||
import type { ModelToolProviderLike } from './model-tool-provider'
|
||||
import type { ModelToolDefinition, ModelToolProviderLike } from './model-tool-provider'
|
||||
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
|
||||
import type { ControlledHarnessExtensionPackage } from './deepseek-harness-extension-loader'
|
||||
import {
|
||||
@@ -449,6 +449,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
private fatalError?: Error
|
||||
private stderrBytes = 0
|
||||
private readonly sessions = new Map<string, string>()
|
||||
private readonly proxyToolCatalogs = new Map<string, ModelToolDefinition[]>()
|
||||
private readonly sessionInitializations = new Map<
|
||||
string,
|
||||
Promise<string>
|
||||
@@ -844,12 +845,11 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
context,
|
||||
connection.signal
|
||||
)
|
||||
return {
|
||||
tools: boundedProxyToolCatalog(
|
||||
tools,
|
||||
context.workMode
|
||||
)
|
||||
}
|
||||
const catalog = boundedProxyToolCatalog(tools, context.workMode)
|
||||
this.proxyToolCatalogs.set(params.sessionId, tools.filter(
|
||||
(tool) => tool.source === 'mcp' || isMainWebTool(tool)
|
||||
))
|
||||
return { tools: catalog }
|
||||
}
|
||||
if (method === GOODBUDDY_TOOLS_CALL) {
|
||||
const sessionId = params.sessionId
|
||||
@@ -882,10 +882,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
knowledgeCapabilityToken:
|
||||
run.request.knowledgeCapabilityToken
|
||||
}
|
||||
const tools = await this.options.toolProvider.listTools(
|
||||
context,
|
||||
run.toolController.signal
|
||||
)
|
||||
const tools = this.proxyToolCatalogs.get(sessionId as string) ?? []
|
||||
const tool = tools.find(
|
||||
(candidate) =>
|
||||
candidate.name === name &&
|
||||
@@ -1599,6 +1596,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
const sessionId = this.sessions.get(conversationId)
|
||||
this.sessions.delete(conversationId)
|
||||
if (sessionId) this.proxyToolCatalogs.delete(sessionId)
|
||||
if (!sessionId || !this.state) {
|
||||
await this.options.toolProvider
|
||||
?.releaseConversation(conversationId)
|
||||
@@ -1646,6 +1644,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
this.activeRuns.clear()
|
||||
this.sessions.clear()
|
||||
this.proxyToolCatalogs.clear()
|
||||
this.sessionInitializations.clear()
|
||||
this.conversationTails.clear()
|
||||
if (!state) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type MagicNotesDatabase
|
||||
} from './knowledge-mcp-gateway'
|
||||
import { browserTabIdSchema } from '../../shared/contracts'
|
||||
import { BrowserService } from '../browser/browser-service'
|
||||
|
||||
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
|
||||
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
|
||||
@@ -231,6 +232,81 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('KnowledgeMcpGateway', () => {
|
||||
it('grants reserved parent and inherited child targets without materializing a browser', async () => {
|
||||
const { service } = createService()
|
||||
const createSession = vi.fn(async (): Promise<never> => {
|
||||
throw new Error('unused requests must not create a browser')
|
||||
})
|
||||
const browserService = new BrowserService({ createSession })
|
||||
const gateway = new KnowledgeMcpGateway(service, { browserService })
|
||||
gateways.push(gateway)
|
||||
const parentController = new AbortController()
|
||||
const parent = browserService.reserveRequestTab('conversation', 'parent', 21)
|
||||
const child = browserService.acquireTabUsage('conversation', parent.tabId, 'child', 21)
|
||||
try {
|
||||
expect(browserService.listTabs('conversation')).toEqual([])
|
||||
expect(() => gateway.grant('unleased', [], parentController.signal, 'none', undefined, 'conversation', parent.tabId))
|
||||
.toThrow('不属于当前对话')
|
||||
const parentToken = gateway.grant('parent', [], parentController.signal, 'none', undefined, 'conversation', parent.tabId, parent)!
|
||||
const childToken = gateway.grant('child', [], new AbortController().signal, 'none', undefined, 'conversation', child.tabId, child)!
|
||||
expect(gateway.getAvailableToolNames(parentToken)).toEqual(browserToolNames)
|
||||
expect(gateway.getAvailableToolNames(childToken)).toEqual(browserToolNames)
|
||||
expect(createSession).not.toHaveBeenCalled()
|
||||
parentController.abort()
|
||||
expect(parent.signal.aborted).toBe(true)
|
||||
expect(child.signal.aborted).toBe(false)
|
||||
expect(browserService.getOwnerWindowId('conversation')).toBe(21)
|
||||
|
||||
await gateway.start()
|
||||
const navigate = vi.spyOn(browserService, 'navigate').mockResolvedValue({
|
||||
url: 'https://example.com/',
|
||||
origin: 'https://example.com'
|
||||
})
|
||||
const client = new Client({ name: 'reserved-child-test', version: '1.0.0' })
|
||||
try {
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), {
|
||||
requestInit: { headers: { Authorization: `Bearer ${childToken}` } }
|
||||
}))
|
||||
await client.callTool({ name: 'browser_navigate', arguments: { url: 'https://example.com/' } })
|
||||
expect(navigate).toHaveBeenCalledWith('conversation', 'https://example.com/', expect.any(AbortSignal), parent.tabId)
|
||||
expect(createSession).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
gateway.revoke(childToken)
|
||||
expect(child.signal.aborted).toBe(true)
|
||||
expect(browserService.getOwnerWindowId('conversation')).toBeUndefined()
|
||||
} finally {
|
||||
await gateway.dispose()
|
||||
await browserService.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects mismatched or ended reservation leases and releases pre-canceled grants', async () => {
|
||||
const { service } = createService()
|
||||
const browserService = new BrowserService()
|
||||
const gateway = new KnowledgeMcpGateway(service, { browserService })
|
||||
gateways.push(gateway)
|
||||
try {
|
||||
const mismatch = browserService.reserveRequestTab('conversation', 'owner', 21)
|
||||
expect(() => gateway.grant('other-owner', [], new AbortController().signal, 'none', undefined, 'conversation', mismatch.tabId, mismatch))
|
||||
.toThrow('与当前请求不匹配')
|
||||
expect(mismatch.signal.aborted).toBe(true)
|
||||
const released = browserService.reserveRequestTab('conversation', 'released', 21)
|
||||
released.release()
|
||||
expect(() => gateway.grant('released', [], new AbortController().signal, 'none', undefined, 'conversation', released.tabId, released))
|
||||
.toThrow('租约已终止')
|
||||
const canceled = browserService.reserveRequestTab('conversation', 'canceled', 21)
|
||||
expect(() => gateway.grant('canceled', [], AbortSignal.abort(new Error('request canceled')), 'none', undefined, 'conversation', canceled.tabId, canceled))
|
||||
.toThrow('request canceled')
|
||||
expect(canceled.signal.aborted).toBe(true)
|
||||
expect(browserService.getOwnerWindowId('conversation')).toBeUndefined()
|
||||
expect(browserService.getSessionCount()).toBe(0)
|
||||
} finally {
|
||||
await browserService.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the assigned browser through a request-scoped conversation', async () => {
|
||||
const { service } = createService()
|
||||
const browserTabId = browserTabIdSchema.parse(
|
||||
|
||||
@@ -472,9 +472,9 @@ export class KnowledgeMcpGateway {
|
||||
if (
|
||||
effectiveBrowserConversationId &&
|
||||
(!effectiveBrowserTabId ||
|
||||
!this.browserService!
|
||||
(!browserUsageLease && !this.browserService!
|
||||
.listTabs(effectiveBrowserConversationId)
|
||||
.some((tab) => tab.tabId === effectiveBrowserTabId))
|
||||
.some((tab) => tab.tabId === effectiveBrowserTabId)))
|
||||
) {
|
||||
throw new Error('浏览器标签页不存在或不属于当前对话')
|
||||
}
|
||||
@@ -507,8 +507,9 @@ export class KnowledgeMcpGateway {
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
effectiveBrowserUsageLease?.signal.throwIfAborted()
|
||||
return this.storeCapability({
|
||||
requestId,
|
||||
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
|
||||
|
||||
@@ -2,8 +2,10 @@ import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative } from 'node:path'
|
||||
import { rgPath } from '@vscode/ripgrep'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ModelToolProvider,
|
||||
RecoverableModelToolError,
|
||||
type ModelToolDefinition,
|
||||
type ModelToolProviderLike,
|
||||
@@ -12,6 +14,10 @@ import {
|
||||
import { ModelAgentRuntime } from './model-runtime'
|
||||
import type { RuntimeEvent } from './runtime'
|
||||
import { SubagentScheduler } from '../assistant/subagent-scheduler'
|
||||
import { LocalWorkspaceAccess } from '../workspace'
|
||||
import { LocalDirectModelProcessService } from './direct-model-process-service'
|
||||
import { browserTabIdSchema } from '../../shared/contracts'
|
||||
import type { BrowserToolService } from '../browser/browser-model-tools'
|
||||
|
||||
const toolPng = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47,
|
||||
@@ -2193,39 +2199,151 @@ describe('ModelAgentRuntime', () => {
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
})
|
||||
|
||||
it('keeps browser and workspace tools out of Ask mode', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
new Response('data: {"choices":[{"delta":{"content":"只读回答"}}]}\n\ndata: [DONE]\n\n', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' }
|
||||
})
|
||||
it('runs Ask workspace search, text reads and output paging with the production deny authorizer and no optional capabilities', async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-ask-tools-'))
|
||||
const workspaceAccess = new LocalWorkspaceAccess(workspace)
|
||||
const processService = new LocalDirectModelProcessService()
|
||||
const toolProvider = new ModelToolProvider(
|
||||
workspaceAccess, [], undefined, undefined, false,
|
||||
{ processService, ripgrepExecutablePath: rgPath }
|
||||
)
|
||||
const signal = new AbortController().signal
|
||||
const conversationId = 'conversation-ask'
|
||||
const responses: unknown[] = []
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(responses.shift()))
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'https://example.test/v1',
|
||||
model: 'test-model',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
fetcher,
|
||||
toolProvider,
|
||||
workspaceAccess
|
||||
})
|
||||
const authorize = vi.fn(async () => 'deny' as const)
|
||||
try {
|
||||
await writeFile(join(workspace, 'README.md'), 'readonly-marker\n')
|
||||
await writeFile(join(workspace, 'output.cjs'),
|
||||
"process.stdout.write('x'.repeat(110000) + 'retained-tail')")
|
||||
// Seed output as a preceding Execute operation in the same conversation.
|
||||
const previous = await processService.execute(
|
||||
{ command: 'node output.cjs', timeoutMs: 10_000 },
|
||||
{ conversationId, workspace: workspaceAccess, signal }
|
||||
)
|
||||
const toolProvider = createToolProvider()
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
model: 'qwen3',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
fetcher,
|
||||
toolProvider
|
||||
expect(previous.exitCode).toBe(0)
|
||||
const reference = previous.stdoutReference!
|
||||
expect(reference).toBeDefined()
|
||||
const calls = [
|
||||
{ name: 'workspace_rg', arguments: { pattern: 'readonly-marker', glob: ['README.md'] } },
|
||||
{ name: 'workspace_read_text', arguments: { path: 'README.md' } },
|
||||
{ name: 'output_read', arguments: { handle: reference.handle, cursor: reference.nextCursor } }
|
||||
]
|
||||
responses.push({
|
||||
choices: [{ message: {
|
||||
role: 'assistant', content: null,
|
||||
tool_calls: calls.map((call, index) => ({
|
||||
id: `ask-${index}`, type: 'function',
|
||||
function: { name: call.name, arguments: JSON.stringify(call.arguments) }
|
||||
}))
|
||||
} }]
|
||||
}, { choices: [{ message: { role: 'assistant', content: '只读完成' } }] })
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId,
|
||||
prompt: '搜索并读取文件,续读已有输出', workMode: 'ask'
|
||||
}, signal, authorize)) events.push(event)
|
||||
|
||||
const firstBody = JSON.parse(String(fetcher.mock.calls[0]![1]!.body))
|
||||
expect(firstBody.tools.map((tool: { function: { name: string } }) =>
|
||||
tool.function.name)).toEqual(['workspace_rg', 'workspace_read_text', 'output_read'])
|
||||
const secondBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body))
|
||||
const results = secondBody.messages.filter(
|
||||
(message: { role: string }) => message.role === 'tool'
|
||||
)
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results[0].content).toContain('README.md:1:1:readonly-marker')
|
||||
expect(results[1].content).toContain('readonly-marker')
|
||||
expect(JSON.parse(results[2].content)).toMatchObject({
|
||||
content: expect.stringContaining('retained-tail'), eof: true
|
||||
})
|
||||
|
||||
for await (const _event of runtime.run(
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: 'conversation-ask',
|
||||
prompt: '只读',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)) {
|
||||
void _event
|
||||
}
|
||||
|
||||
expect(toolProvider.listTools).not.toHaveBeenCalled()
|
||||
expect(toolProvider.callTool).not.toHaveBeenCalled()
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
expect(events.filter((event) =>
|
||||
event.type === 'tool' && event.state === 'completed')).toHaveLength(3)
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
expect(await readFile(join(workspace, 'README.md'), 'utf8')).toBe('readonly-marker\n')
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['workspace_apply_patch', 'workspace_write_text', 'process_execute', 'browser_snapshot'])(
|
||||
'rejects unlisted %s in Ask at the runtime boundary',
|
||||
async (name) => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-ask-denial-'))
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json({
|
||||
choices: [{ message: {
|
||||
role: 'assistant', content: null,
|
||||
tool_calls: [{ id: 'spoof', type: 'function', function: {
|
||||
name, arguments: JSON.stringify({
|
||||
path: 'forbidden.txt', content: 'forbidden',
|
||||
patch: '*** Begin Patch\n*** Add File: forbidden.txt\n+forbidden\n*** End Patch',
|
||||
command: 'echo forbidden'
|
||||
})
|
||||
} }]
|
||||
} }]
|
||||
}))
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'https://example.test/v1', model: 'test-model',
|
||||
protocol: 'openai-chat-completions', authentication: 'none',
|
||||
fetcher, defaultWorkspace: workspace
|
||||
})
|
||||
const authorize = vi.fn(async () => 'deny' as const)
|
||||
try {
|
||||
const consume = async () => {
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId: 'ask-denied',
|
||||
prompt: '只读', workMode: 'ask'
|
||||
}, new AbortController().signal, authorize)) void event
|
||||
}
|
||||
await expect(consume()).rejects.toThrow('未知工具')
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
await expect(readFile(join(workspace, 'forbidden.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['openai-chat-completions', 'openai-responses', 'anthropic-messages'] as const)(
|
||||
'lists only workspace reads and retained output in plain Ask via %s',
|
||||
async (protocol) => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(
|
||||
protocol === 'anthropic-messages'
|
||||
? { content: [{ type: 'text', text: '只读回答' }], stop_reason: 'end_turn' }
|
||||
: protocol === 'openai-responses'
|
||||
? { id: 'resp-ask', output: [{ type: 'message', content: [{ type: 'output_text', text: '只读回答' }] }] }
|
||||
: { choices: [{ message: { role: 'assistant', content: '只读回答' } }] }
|
||||
))
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'https://example.test/v1', model: 'test-model',
|
||||
protocol, authentication: 'none', fetcher
|
||||
})
|
||||
try {
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId: 'plain-ask',
|
||||
prompt: '只读', workMode: 'ask'
|
||||
}, new AbortController().signal, async () => 'deny')) void event
|
||||
const body = JSON.parse(String(fetcher.mock.calls[0]![1]!.body))
|
||||
expect(body.tools.map((tool: { name?: string; function?: { name: string } }) =>
|
||||
tool.function?.name ?? tool.name)).toEqual(['workspace_read_text', 'output_read'])
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('uses the OpenAI Responses endpoint and streams output text', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
new Response(
|
||||
@@ -2603,6 +2721,94 @@ describe('ModelAgentRuntime', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('lets an Execute Subagent borrow the parent browser tab without changing tab ownership', async () => {
|
||||
const conversationId = 'parent-browser-owner'
|
||||
const browserTabId = browserTabIdSchema.parse('00000000-0000-4000-8000-000000000203')
|
||||
let parentReleased = false
|
||||
const browserService: BrowserToolService = {
|
||||
getOrigin: vi.fn(() => 'https://example.test'),
|
||||
navigate: vi.fn(), click: vi.fn(), type: vi.fn(),
|
||||
select: vi.fn(), back: vi.fn(), screenshot: vi.fn(),
|
||||
snapshot: vi.fn(async (owner, _signal, tabId) => {
|
||||
// BrowserService resolves tabs under their owning conversation, not
|
||||
// merely by tab ID; a child's temporary conversation cannot own it.
|
||||
expect(owner).toBe(conversationId)
|
||||
expect(tabId).toBe(browserTabId)
|
||||
expect(parentReleased).toBe(false)
|
||||
return { url: 'https://example.test/', title: 'Parent page', nodes: [], truncated: false }
|
||||
}),
|
||||
releaseConversation: vi.fn(async (owner) => {
|
||||
if (owner === conversationId) parentReleased = true
|
||||
})
|
||||
}
|
||||
const toolResponse = (id: string, name: string, args: Record<string, unknown>) => ({
|
||||
choices: [{ message: {
|
||||
role: 'assistant', content: null,
|
||||
tool_calls: [{ id, type: 'function', function: {
|
||||
name, arguments: JSON.stringify(args)
|
||||
} }]
|
||||
} }]
|
||||
})
|
||||
const responses = [
|
||||
toolResponse('delegate', 'subagent_delegate', { task: '读取父请求浏览器页面' }),
|
||||
toolResponse('child-browser', 'browser_snapshot', {}),
|
||||
{ choices: [{ message: { role: 'assistant', content: '子级已读页面' } }] },
|
||||
toolResponse('parent-browser', 'browser_snapshot', {}),
|
||||
{ choices: [{ message: { role: 'assistant', content: '父级继续使用同一页面' } }] }
|
||||
]
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(responses.shift()))
|
||||
const scheduler = new SubagentScheduler({ concurrency: 1, queueLimit: 1, timeoutMs: 10_000 })
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'https://example.test/v1', model: 'test-model',
|
||||
protocol: 'openai-chat-completions', authentication: 'none',
|
||||
fetcher, browserService, directModelSubagentScheduler: scheduler
|
||||
})
|
||||
const listTools = vi.spyOn(ModelToolProvider.prototype, 'listTools')
|
||||
const callTool = vi.spyOn(ModelToolProvider.prototype, 'callTool')
|
||||
try {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId, browserTabId,
|
||||
prompt: '委派读取页面后继续使用页面', workMode: 'execute'
|
||||
}, new AbortController().signal, async () => 'once')) events.push(event)
|
||||
expect(fetcher).toHaveBeenCalledTimes(5)
|
||||
const childContext = listTools.mock.calls.find(
|
||||
([context]) => context.delegationDepth === 1
|
||||
)![0]
|
||||
expect(childContext).toMatchObject({
|
||||
workMode: 'execute', browserTabId,
|
||||
browserConversationId: conversationId
|
||||
})
|
||||
expect(childContext.conversationId).not.toBe(conversationId)
|
||||
const childBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body))
|
||||
const childTools = childBody.tools.map(
|
||||
(tool: { function: { name: string } }) => tool.function.name
|
||||
)
|
||||
expect(childTools).toEqual(expect.arrayContaining(['browser_navigate', 'browser_snapshot', 'browser_click']))
|
||||
expect(childTools).not.toContain('subagent_delegate')
|
||||
expect(callTool).toHaveBeenCalledWith(
|
||||
'browser_snapshot', {}, expect.any(AbortSignal),
|
||||
expect.objectContaining({
|
||||
conversationId: childContext.conversationId,
|
||||
browserConversationId: conversationId, browserTabId
|
||||
})
|
||||
)
|
||||
expect(browserService.snapshot).toHaveBeenCalledTimes(2)
|
||||
expect(browserService.releaseConversation).toHaveBeenCalledWith(childContext.conversationId)
|
||||
expect(parentReleased).toBe(false)
|
||||
expect(events.filter((event) => event.type === 'subagent').at(-1))
|
||||
.toMatchObject({ state: 'completed' })
|
||||
await runtime.releaseConversation(conversationId)
|
||||
expect(parentReleased).toBe(true)
|
||||
} finally {
|
||||
listTools.mockRestore()
|
||||
callTool.mockRestore()
|
||||
await runtime.dispose()
|
||||
scheduler.dispose()
|
||||
await scheduler.waitForIdle()
|
||||
}
|
||||
})
|
||||
|
||||
it('delegates one programming Subagent that can run a real project command without recursion', async () => {
|
||||
const command =
|
||||
process.platform === 'win32'
|
||||
|
||||
@@ -86,6 +86,12 @@ type ConversationMessage = {
|
||||
content: string
|
||||
}
|
||||
|
||||
// Temporary Subagents retain separate history/output ownership while borrowing
|
||||
// the parent's browser tab and its owning conversation. They acquire no lease.
|
||||
type ModelExecutionRequest = AgentExecutionRequest & {
|
||||
browserConversationId?: string
|
||||
}
|
||||
|
||||
type ProviderConversationMessage = Pick<
|
||||
ConversationMessage,
|
||||
'role' | 'content'
|
||||
@@ -120,6 +126,11 @@ type AgentRunCompressionState = {
|
||||
}
|
||||
|
||||
const scopedReadToolNameSet = new Set<string>(scopedReadToolNames)
|
||||
const askWorkspaceReadToolNames = new Set([
|
||||
'workspace_rg',
|
||||
'workspace_read_text',
|
||||
'output_read'
|
||||
])
|
||||
|
||||
type AnthropicApiMessage = {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -1678,6 +1689,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
{
|
||||
requestId: input.context.childRunId,
|
||||
conversationId: input.context.conversationId,
|
||||
browserTabId: input.requestContext.browserTabId,
|
||||
browserConversationId:
|
||||
input.requestContext.browserConversationId,
|
||||
projectId: input.context.projectId,
|
||||
workMode: input.context.workMode,
|
||||
prompt: input.task,
|
||||
@@ -3331,7 +3345,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
private async *runToolExecution(
|
||||
request: AgentExecutionRequest,
|
||||
request: ModelExecutionRequest,
|
||||
signal: AbortSignal,
|
||||
authorize: RuntimeAuthorizer | undefined,
|
||||
system: string,
|
||||
@@ -3364,6 +3378,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
const toolContext: ModelToolCallContext = {
|
||||
conversationId: request.conversationId,
|
||||
browserTabId: request.browserTabId,
|
||||
browserConversationId: request.browserConversationId,
|
||||
workMode: request.workMode ?? 'ask',
|
||||
requestId: request.requestId,
|
||||
runtimeTarget: 'model',
|
||||
@@ -3376,6 +3391,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
subagentBridge: {
|
||||
requestContext: {
|
||||
authorize,
|
||||
browserTabId: request.browserTabId,
|
||||
browserConversationId:
|
||||
request.browserConversationId ?? request.conversationId,
|
||||
knowledgeCapabilityToken:
|
||||
request.knowledgeCapabilityToken,
|
||||
emitEvent: emitNestedEvent
|
||||
@@ -3730,6 +3748,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
try {
|
||||
if (
|
||||
tool.name === 'subagent_delegate' ||
|
||||
(toolContext.workMode === 'ask' &&
|
||||
askWorkspaceReadToolNames.has(tool.name)) ||
|
||||
(scopedReadToolNameSet.has(tool.name) &&
|
||||
Boolean(request.knowledgeCapabilityToken)) ||
|
||||
tool.name === 'web_search' ||
|
||||
@@ -3894,7 +3914,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async *run(
|
||||
request: AgentExecutionRequest,
|
||||
request: ModelExecutionRequest,
|
||||
signal: AbortSignal,
|
||||
authorize?: RuntimeAuthorizer
|
||||
): AsyncGenerator<RuntimeEvent, void, void> {
|
||||
@@ -3952,7 +3972,10 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
yield result.value
|
||||
}
|
||||
const executionRequest = prepared.request
|
||||
const executionRequest: ModelExecutionRequest = {
|
||||
...prepared.request,
|
||||
browserConversationId: request.browserConversationId
|
||||
}
|
||||
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
@@ -3971,9 +3994,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
if (
|
||||
executionRequest.workMode === 'execute' ||
|
||||
(executionRequest.workMode === 'ask' &&
|
||||
(Boolean(executionRequest.knowledgeCapabilityToken) ||
|
||||
this.options.webSearchEnabled === true ||
|
||||
this.directModelSubagentEnabled))
|
||||
this.toolProvider !== noModelTools)
|
||||
) {
|
||||
yield* this.runToolExecution(
|
||||
executionRequest,
|
||||
|
||||
@@ -235,6 +235,19 @@ describe('ModelToolProvider', () => {
|
||||
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)
|
||||
@@ -243,7 +256,7 @@ describe('ModelToolProvider', () => {
|
||||
tools.find((tool) => tool.serverName === 'Healthy')!.name,
|
||||
{}, signal, toolContext
|
||||
)
|
||||
expect(mocks.client.callTool).toHaveBeenCalledOnce()
|
||||
expect(mocks.client.callTool).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
await provider.dispose()
|
||||
warning.mockRestore()
|
||||
@@ -251,6 +264,33 @@ describe('ModelToolProvider', () => {
|
||||
}
|
||||
)
|
||||
|
||||
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' } }]
|
||||
@@ -302,7 +342,7 @@ describe('ModelToolProvider', () => {
|
||||
])
|
||||
const signal = new AbortController().signal
|
||||
try {
|
||||
await provider.listTools(toolContext, signal)
|
||||
const originalTools = await provider.listTools(toolContext, signal)
|
||||
const options = mocks.Client.mock.calls[0]![1] as {
|
||||
listChanged: { tools: { onChanged(error: Error | null): void } }
|
||||
}
|
||||
@@ -312,6 +352,15 @@ describe('ModelToolProvider', () => {
|
||||
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)
|
||||
|
||||
@@ -291,6 +291,7 @@ export type ModelToolResult = {
|
||||
export type ModelToolCallContext = {
|
||||
conversationId: string
|
||||
browserTabId?: BrowserTabId
|
||||
browserConversationId?: string
|
||||
workMode: 'ask' | 'execute'
|
||||
requestId?: string
|
||||
runtimeTarget?: 'model'
|
||||
@@ -302,6 +303,8 @@ export type ModelToolCallContext = {
|
||||
|
||||
export type ModelSubagentRequestContext = {
|
||||
authorize?: RuntimeAuthorizer
|
||||
browserTabId?: BrowserTabId
|
||||
browserConversationId?: string
|
||||
knowledgeCapabilityToken?: string
|
||||
emitEvent: (event: RuntimeEvent) => void
|
||||
}
|
||||
@@ -663,6 +666,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
Promise<ConnectedMcp | undefined>
|
||||
>()
|
||||
private webSearchBindings?: Promise<Map<string, McpToolBinding>>
|
||||
private mcpBindings = new Map<string, McpToolBinding>()
|
||||
private readonly clients = new Set<Client>()
|
||||
private readonly customMcpClients = new Set<Client>()
|
||||
private readonly webSearchClients = new Set<Client>()
|
||||
@@ -739,7 +743,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
context.browserTabId
|
||||
? new BrowserModelTools({
|
||||
service: this.browserService,
|
||||
conversationId: context.conversationId,
|
||||
conversationId:
|
||||
context.browserConversationId ?? context.conversationId,
|
||||
browserTabId: context.browserTabId
|
||||
})
|
||||
: undefined
|
||||
@@ -1211,6 +1216,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
bindings.set(binding.definition.name, binding)
|
||||
}
|
||||
}
|
||||
this.mcpBindings = bindings
|
||||
return bindings
|
||||
}
|
||||
|
||||
@@ -1823,7 +1829,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
)
|
||||
}
|
||||
|
||||
const binding = (await this.getMcpBindings(signal)).get(name)
|
||||
// Calls use the discovered catalog; only listTools retries failed servers.
|
||||
const binding = this.mcpBindings.get(name)
|
||||
if (!binding) {
|
||||
throw new Error('模型请求了未知工具')
|
||||
}
|
||||
@@ -1881,6 +1888,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
this.customMcpClients.clear()
|
||||
this.webSearchClients.clear()
|
||||
this.mcpConnections.clear()
|
||||
this.mcpBindings.clear()
|
||||
this.webSearchBindings = undefined
|
||||
await Promise.allSettled([
|
||||
...clients.map((client) => client.close()),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyRuntimeSelection } from './runtime-selection'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import { defaultRuntimeCustomizationSettings } from '../../shared/contracts'
|
||||
import {
|
||||
repairChannelRuntimeSelection,
|
||||
type AgentRuntimeSelection,
|
||||
type RuntimeSelectionRepairSettings
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
|
||||
const defaultId = '00000000-0000-4000-8000-000000000001'
|
||||
const fallbackId = '00000000-0000-4000-8000-000000000002'
|
||||
const missingId = '00000000-0000-4000-8000-000000000003'
|
||||
|
||||
function settings(
|
||||
defaultProfile: Partial<RuntimeSelectionRepairSettings['modelProfiles'][number]>
|
||||
): RuntimeSelectionRepairSettings {
|
||||
return {
|
||||
defaultModelProfileId: defaultId,
|
||||
modelProfiles: [
|
||||
{
|
||||
id: defaultId, protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key', apiKeyConfigured: true, ...defaultProfile
|
||||
},
|
||||
{
|
||||
id: fallbackId, protocol: 'openai-chat-completions',
|
||||
authentication: 'none', apiKeyConfigured: false
|
||||
}
|
||||
],
|
||||
opencodeModelSource: { kind: 'platform' },
|
||||
continueModelSource: { kind: 'platform' }
|
||||
}
|
||||
}
|
||||
|
||||
function resolvedSettings(input: RuntimeSelectionRepairSettings): ResolvedRuntimeSettings {
|
||||
return {
|
||||
provider: 'model', modelBaseUrl: 'https://model.example/v1', modelName: 'default',
|
||||
modelProtocol: 'openai-chat-completions', modelAuthentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
modelProfiles: input.modelProfiles.map((profile) => ({
|
||||
id: profile.id, name: profile.id, baseUrl: 'https://model.example/v1',
|
||||
modelName: profile.id,
|
||||
protocol: profile.protocol as ResolvedRuntimeSettings['modelProtocol'],
|
||||
authentication: profile.authentication!,
|
||||
apiKey: profile.apiKeyConfigured ? 'fixture-key' : undefined
|
||||
})),
|
||||
defaultModelProfileId: input.defaultModelProfileId,
|
||||
opencodeBaseUrl: '', opencodeEmbedded: false, opencodeBinaryPath: '',
|
||||
opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '',
|
||||
continueMode: 'chat', subagentSmartRoutingEnabled: false,
|
||||
knowledgeEmbeddingEnabled: false, knowledgeEmbeddingBaseUrl: '',
|
||||
knowledgeEmbeddingModel: '', knowledgeRerankEnabled: false,
|
||||
knowledgeRerankEndpoint: '', knowledgeRerankModel: '',
|
||||
runtimeCustomization: defaultRuntimeCustomizationSettings,
|
||||
workspacePath: '.', toolApproval: 'always'
|
||||
}
|
||||
}
|
||||
|
||||
describe('channel direct model repair', () => {
|
||||
const selections: AgentRuntimeSelection[] = [
|
||||
{ provider: 'auto' },
|
||||
{ provider: 'model' },
|
||||
{ provider: 'model', profileId: defaultId },
|
||||
{ provider: 'model', profileId: missingId }
|
||||
]
|
||||
it.each([
|
||||
{ protocol: 'openai-images-generations' },
|
||||
{ apiKeyConfigured: false },
|
||||
{ id: missingId }
|
||||
])('pins a verified fallback when the default is unusable: %j', (defaultProfile) => {
|
||||
const input = settings(defaultProfile)
|
||||
if (defaultProfile.id) input.modelProfiles = input.modelProfiles.slice(1)
|
||||
for (const selection of selections) {
|
||||
const repaired = repairChannelRuntimeSelection(selection, input)
|
||||
expect(repaired).toEqual({ provider: 'model', profileId: fallbackId })
|
||||
const resolved = applyRuntimeSelection(resolvedSettings(input), repaired)
|
||||
expect(resolved.settings.defaultModelProfileId).toBe(fallbackId)
|
||||
expect(resolved.settings.modelProtocol).toBe('openai-chat-completions')
|
||||
expect(resolved.settings.modelAuthentication).toBe('none')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a healthy default dynamic and preserves valid explicit pins', () => {
|
||||
const input = settings({})
|
||||
for (const selection of [selections[0]!, selections[1]!, selections[3]!]) {
|
||||
const repaired = repairChannelRuntimeSelection(selection, input)
|
||||
expect(repaired).toEqual({ provider: 'model' })
|
||||
const changed = { ...resolvedSettings(input), defaultModelProfileId: fallbackId }
|
||||
expect(applyRuntimeSelection(changed, repaired).settings.defaultModelProfileId).toBe(fallbackId)
|
||||
}
|
||||
for (const profileId of [defaultId, fallbackId]) {
|
||||
const selection = { provider: 'model', profileId } as const
|
||||
expect(repairChannelRuntimeSelection(selection, input)).toEqual(selection)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a valid pin even when the global default is an image model', () => {
|
||||
const input = settings({ protocol: 'openai-images-generations' })
|
||||
const selection = { provider: 'model', profileId: fallbackId } as const
|
||||
expect(repairChannelRuntimeSelection(selection, input)).toEqual(selection)
|
||||
})
|
||||
|
||||
it('does not invent a profile when no usable fallback exists', () => {
|
||||
const input = settings({ apiKeyConfigured: false })
|
||||
input.modelProfiles = input.modelProfiles.slice(0, 1)
|
||||
expect(repairChannelRuntimeSelection({ provider: 'auto' }, input))
|
||||
.toEqual({ provider: 'model' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user