feat: add persistent workbar browser tabs and scoped MCP routing

This commit is contained in:
mesalogo
2026-09-10 01:26:56 +08:00
parent 428b4c0d08
commit 80b0b18712
54 changed files with 5446 additions and 1468 deletions
@@ -5,7 +5,6 @@ import { join, relative } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LocalWorkspaceAccess } from '../workspace'
import {
DIRECT_MODEL_PROCESS_TRUNCATION_MARKER,
LocalDirectModelProcessService,
buildDirectModelProcessEnvironment,
type DirectModelProcessChild,
@@ -277,7 +276,7 @@ describe('LocalDirectModelProcessService', () => {
await service.dispose()
})
it('returns nonzero exits normally and bounds stdout and stderr by retaining both edges', async () => {
it('returns nonzero exits with bounded previews and retains full output for paging', async () => {
const { access } = await workspace()
const child = new FakeChild()
let now = 1_000
@@ -310,12 +309,19 @@ describe('LocalDirectModelProcessService', () => {
expect(result.durationMs).toBe(125)
expect(result.stdoutTruncated).toBe(true)
expect(result.stdout).toBe(
`${'A'.repeat(48 * 1024)}` +
DIRECT_MODEL_PROCESS_TRUNCATION_MARKER +
'Z'.repeat(48 * 1024)
'A'.repeat(48 * 1024) + 'M'.repeat(10) + 'Z'.repeat(48 * 1024 - 10)
)
expect(result.stderr).toBe('ordinary error output')
expect(result.stderrTruncated).toBe(false)
expect(result.stderrReference).toBeUndefined()
const reference = result.stdoutReference!
expect(reference.totalBytes).toBe(96 * 1024 + 10)
await expect(service.readOutput('output', reference.handle, reference.nextCursor)).resolves.toMatchObject({
content: 'Z'.repeat(10), eof: true, nextCursor: reference.totalBytes
})
await expect(service.readOutput('other', reference.handle)).rejects.toThrow()
await service.releaseConversation('output')
await expect(service.readOutput('output', reference.handle)).rejects.toThrow()
await service.dispose()
})
@@ -439,14 +445,18 @@ describe('LocalDirectModelProcessService', () => {
}
)
const first = run('first')
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
const second = run('second')
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledTimes(2))
firstChild.stdout.emit('data', 'x'.repeat(120000))
secondChild.stderr.emit('data', 'y'.repeat(120000))
await service.releaseConversation('first')
expect(terminated).toHaveLength(1)
await expect(first).resolves.toMatchObject({
terminationReason: 'cancelled'
})
await expect(service.readOutput('first', (await first).stdoutReference!.handle)).rejects.toThrow()
const replacementChild = new FakeChild()
children.push(replacementChild)
const replacement = run('first')
@@ -464,6 +474,7 @@ describe('LocalDirectModelProcessService', () => {
await expect(second).resolves.toMatchObject({
terminationReason: 'cancelled'
})
await expect(service.readOutput('second', (await second).stderrReference!.handle)).rejects.toThrow()
await service.dispose()
})
@@ -496,4 +507,37 @@ describe('LocalDirectModelProcessService', () => {
expect(result.stderr).toContain('warn')
await service.dispose()
})
it('pages complete stdout and stderr from a real shell', async () => {
const { access } = await workspace()
const service = new LocalDirectModelProcessService()
try {
expect((await service.getCapability()).available).toBe(true)
const command = process.platform === 'win32'
? "[Console]::Out.Write(('x' * 120000) + 'stdout-end'); [Console]::Error.Write(('y' * 120000) + 'stderr-end')"
: "printf '%120000sstdout-end' '' | tr ' ' x; printf '%120000sstderr-end' '' | tr ' ' y >&2"
const result = await service.execute({ command }, {
conversationId: 'real-pages', workspace: access,
signal: new AbortController().signal
})
for (const [preview, reference, expected] of [
[result.stdout, result.stdoutReference, 'x'.repeat(120000) + 'stdout-end'],
[result.stderr, result.stderrReference, 'y'.repeat(120000) + 'stderr-end']
] as const) {
expect(reference).toBeDefined()
let output = preview
let cursor = reference!.nextCursor
while (cursor < reference!.totalBytes) {
const page = await service.readOutput('real-pages', reference!.handle, cursor, 4096)
output += page.content
cursor = page.nextCursor
}
expect(output).toBe(expected)
}
await service.dispose()
await expect(service.readOutput('real-pages', result.stdoutReference!.handle)).rejects.toThrow()
} finally {
await service.dispose()
}
})
})
+64 -96
View File
@@ -15,16 +15,18 @@ import {
type WaitableProcessTreeChild
} from './child-process-termination'
import { buildCredentialFilteredUserEnvironment } from './process-environment'
import {
PagedOutputStore,
type PagedOutputPage,
type PagedOutputReference,
type PagedOutputWriter
} from './paged-output-store'
const DEFAULT_TIMEOUT_MS = 120_000
const MAXIMUM_TIMEOUT_MS = 10 * 60_000
const MAXIMUM_OUTPUT_BYTES = 96 * 1024
const RETAINED_OUTPUT_EDGE_BYTES = MAXIMUM_OUTPUT_BYTES / 2
const TERMINATION_WAIT_MS = 2_000
export const DIRECT_MODEL_PROCESS_TRUNCATION_MARKER =
'\n...[GoodBuddy output truncated]...\n'
export const processExecuteInputSchema = z
.object({
command: z.string().trim().min(1).max(100_000),
@@ -70,6 +72,8 @@ export type ProcessExecuteResult = {
stderr: string
stdoutTruncated: boolean
stderrTruncated: boolean
stdoutReference?: PagedOutputReference
stderrReference?: PagedOutputReference
}
export type DirectModelProcessCapability =
@@ -94,6 +98,7 @@ export interface DirectModelProcessService {
input: ProcessExecuteInput,
context: DirectModelProcessExecutionContext
): Promise<ProcessExecuteResult>
readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise<PagedOutputPage>
releaseConversation(conversationId: string): Promise<void>
dispose(): Promise<void>
}
@@ -271,67 +276,6 @@ async function resolveProcessShell(
return undefined
}
class BoundedProcessOutput {
private prefix = Buffer.alloc(0)
private suffix = Buffer.alloc(0)
private totalBytes = 0
append(chunk: Buffer | string): void {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
this.totalBytes += value.byteLength
if (this.prefix.byteLength < RETAINED_OUTPUT_EDGE_BYTES) {
const remaining =
RETAINED_OUTPUT_EDGE_BYTES - this.prefix.byteLength
this.prefix = Buffer.concat([
this.prefix,
value.subarray(0, remaining)
])
}
if (value.byteLength >= RETAINED_OUTPUT_EDGE_BYTES) {
this.suffix = Buffer.from(
value.subarray(value.byteLength - RETAINED_OUTPUT_EDGE_BYTES)
)
return
}
const combined = Buffer.concat([this.suffix, value])
this.suffix =
combined.byteLength <= RETAINED_OUTPUT_EDGE_BYTES
? combined
: Buffer.from(
combined.subarray(
combined.byteLength - RETAINED_OUTPUT_EDGE_BYTES
)
)
}
result(): { text: string; truncated: boolean } {
if (this.totalBytes > MAXIMUM_OUTPUT_BYTES) {
return {
text: Buffer.concat([
this.prefix,
Buffer.from(DIRECT_MODEL_PROCESS_TRUNCATION_MARKER),
this.suffix
]).toString('utf8'),
truncated: true
}
}
const overlap = Math.max(
0,
this.prefix.byteLength +
this.suffix.byteLength -
this.totalBytes
)
return {
text: Buffer.concat([
this.prefix,
this.suffix.subarray(overlap)
]).toString('utf8'),
truncated: false
}
}
}
function shellArguments(
shell: ResolvedProcessShell,
command: string
@@ -399,6 +343,7 @@ export class LocalDirectModelProcessService
implements DirectModelProcessService
{
private readonly activeCalls = new Map<string, Set<ActiveCall>>()
private readonly outputs = new PagedOutputStore('process')
private readonly shell: Promise<ResolvedProcessShell | undefined>
private disposed = false
private disposePromise?: Promise<void>
@@ -496,31 +441,46 @@ export class LocalDirectModelProcessService
platform
)
const startedAt = (this.options.now ?? Date.now)()
let child: DirectModelProcessChild
try {
child = (
this.options.spawnProcess ??
(spawn as unknown as DirectModelProcessSpawn)
)(shell.executable, shellArguments(shell, input.command), {
cwd: workingDirectory.canonicalPath,
detached: platform !== 'win32',
env: environment,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
} catch (error) {
throw new Error('无法启动命令 Shell', { cause: error })
}
return await this.waitForProcess(
child,
shell,
workingDirectory.displayPath,
input.timeoutMs,
signal,
startedAt
const stdout = await this.outputs.create(
context.conversationId, MAXIMUM_OUTPUT_BYTES
)
let stderr: PagedOutputWriter | undefined
try {
stderr = await this.outputs.create(
context.conversationId, MAXIMUM_OUTPUT_BYTES
)
signal.throwIfAborted()
let child: DirectModelProcessChild
try {
child = (
this.options.spawnProcess ??
(spawn as unknown as DirectModelProcessSpawn)
)(shell.executable, shellArguments(shell, input.command), {
cwd: workingDirectory.canonicalPath,
detached: platform !== 'win32',
env: environment,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
} catch (error) {
throw new Error('无法启动命令 Shell', { cause: error })
}
return await this.waitForProcess(
child,
shell,
workingDirectory.displayPath,
input.timeoutMs,
signal,
startedAt,
stdout,
stderr
)
} catch (error) {
await Promise.all([stdout.abort(), stderr?.abort()])
throw error
}
}
private waitForProcess(
@@ -529,10 +489,10 @@ export class LocalDirectModelProcessService
cwd: string,
timeoutMs: number,
cancellationSignal: AbortSignal,
startedAt: number
startedAt: number,
stdout: PagedOutputWriter,
stderr: PagedOutputWriter
): Promise<ProcessExecuteResult> {
const stdout = new BoundedProcessOutput()
const stderr = new BoundedProcessOutput()
child.stdout?.on('data', (chunk) => stdout.append(chunk))
child.stderr?.on('data', (chunk) => stderr.append(chunk))
@@ -550,9 +510,9 @@ export class LocalDirectModelProcessService
child.stdout?.removeListener?.('error', failFromOutput)
child.stderr?.removeListener?.('error', failFromOutput)
}
const result = (): ProcessExecuteResult => {
const stdoutResult = stdout.result()
const stderrResult = stderr.result()
const result = async (): Promise<ProcessExecuteResult> => {
const stdoutResult = await stdout.finish()
const stderrResult = await stderr.finish()
return {
shell: { kind: shell.kind, label: shell.label },
cwd,
@@ -566,7 +526,9 @@ export class LocalDirectModelProcessService
stdout: stdoutResult.text,
stderr: stderrResult.text,
stdoutTruncated: stdoutResult.truncated,
stderrTruncated: stderrResult.truncated
stderrTruncated: stderrResult.truncated,
...(stdoutResult.reference ? { stdoutReference: stdoutResult.reference } : {}),
...(stderrResult.reference ? { stderrReference: stderrResult.reference } : {})
}
}
const finishResult = (): void => {
@@ -682,6 +644,11 @@ export class LocalDirectModelProcessService
call.controller.abort(abortError('对话进程资源已释放'))
}
await Promise.all(calls.map((call) => call.done))
await this.outputs.releaseOwner(conversationId)
}
readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise<PagedOutputPage> {
return this.outputs.read(ownerId, handle, cursor, limitBytes)
}
dispose(): Promise<void> {
@@ -698,5 +665,6 @@ export class LocalDirectModelProcessService
call.controller.abort(abortError('进程执行服务已关闭'))
}
await Promise.all(calls.map((call) => call.done))
await this.outputs.dispose()
}
}
+108 -3
View File
@@ -22,6 +22,7 @@ import {
KnowledgeMcpGateway,
type MagicNotesDatabase
} from './knowledge-mcp-gateway'
import { browserTabIdSchema } from '../../shared/contracts'
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
@@ -185,6 +186,28 @@ function testTool(name: string): Tool {
}
const gateways: KnowledgeMcpGateway[] = []
it('honors a requested knowledge result count above eight', async () => {
const { service, searchHybridMany } = createService()
const [sample] = await searchHybridMany([firstLibraryId])
searchHybridMany.mockClear()
searchHybridMany.mockResolvedValue(Array.from({ length: 12 }, (_, index) => ({
...sample!,
result: {
...sample!.result,
chunk: { ...sample!.result.chunk, id: crypto.randomUUID() },
rank: index + 1
}
})))
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const controller = new AbortController()
const token = gateway.grant('large-search', [firstLibraryId], controller.signal)!
const references = await gateway.search(token, { query: 'evidence', limit: 12 })
expect(references).toHaveLength(12)
expect(searchHybridMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', 12, controller.signal)
await expect(gateway.search(token, { query: 'evidence', limit: 101 })).rejects.toThrow()
})
const databases: AssistantDatabase[] = []
const temporaryDirectories: string[] = []
const httpServers: Server[] = []
@@ -210,6 +233,22 @@ afterEach(async () => {
describe('KnowledgeMcpGateway', () => {
it('exposes the assigned browser through a request-scoped conversation', async () => {
const { service } = createService()
const browserTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000301'
)
const primaryBrowserTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000302'
)
const releaseBrowserUsage = vi.fn()
const acquireTabUsage = vi.fn(
(conversationId: string, tabId: typeof browserTabId, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release: releaseBrowserUsage
})
)
const browserService = {
getOrigin: vi.fn(() => undefined),
navigate: vi.fn(async () => ({
@@ -222,21 +261,63 @@ describe('KnowledgeMcpGateway', () => {
select: vi.fn(),
back: vi.fn(),
screenshot: vi.fn(),
releaseConversation: vi.fn(async () => undefined)
releaseConversation: vi.fn(async () => undefined),
acquireTabUsage,
listTabs: vi.fn(() => [
{
conversationId: 'browser-conversation',
tabId: primaryBrowserTabId,
primary: true,
status: 'ready' as const,
isLoading: false,
canGoBack: false,
createdAt: 1,
updatedAt: 1
},
{
conversationId: 'browser-conversation',
tabId: browserTabId,
primary: false,
status: 'ready' as const,
isLoading: false,
canGoBack: false,
createdAt: 1,
updatedAt: 1
}
])
}
const gateway = new KnowledgeMcpGateway(service, {
browserService
})
gateways.push(gateway)
await gateway.start()
expect(() =>
gateway.grant(
'wrong-browser-tab',
[],
new AbortController().signal,
'none',
undefined,
'browser-conversation',
browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000399'
)
)
).toThrow('浏览器标签页不存在或不属于当前对话')
const token = gateway.grant(
'browser-request',
[],
new AbortController().signal,
'none',
undefined,
'browser-conversation'
'browser-conversation',
browserTabId
)!
expect(acquireTabUsage).toHaveBeenCalledWith(
'browser-conversation',
browserTabId,
'browser-request'
)
expect(gateway.getAvailableToolNames(token)).toEqual(browserToolNames)
const client = new Client({
name: 'browser-loopback-test',
@@ -276,8 +357,32 @@ describe('KnowledgeMcpGateway', () => {
expect(browserService.navigate).toHaveBeenCalledWith(
'browser-conversation',
'https://example.com',
expect.any(AbortSignal)
expect.any(AbortSignal),
browserTabId
)
expect(browserService.listTabs).toHaveBeenCalledTimes(2)
vi.mocked(browserService.click).mockRejectedValueOnce(
new Error('浏览器标签页不存在或不属于当前对话')
)
await expect(
client.callTool({
name: 'browser_click',
arguments: { ref: 'b_boundTabReference' }
})
).resolves.toMatchObject({ isError: true })
expect(browserService.click).toHaveBeenCalledWith(
'browser-conversation',
'b_boundTabReference',
expect.any(AbortSignal),
browserTabId
)
expect(browserService.listTabs).toHaveBeenCalledTimes(2)
gateway.revoke(token)
gateway.revoke(token)
expect(releaseBrowserUsage).toHaveBeenCalledOnce()
await expect(
client.callTool({ name: 'browser_snapshot', arguments: {} })
).rejects.toThrow()
} finally {
await client.close()
}
+104 -25
View File
@@ -19,7 +19,11 @@ import {
type Tool
} from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import type { KnowledgeSearchReference } from '../../shared/contracts'
import type {
BrowserTabId,
BrowserTabSummary,
KnowledgeSearchReference
} from '../../shared/contracts'
import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
import {
knowledgeToolNames,
@@ -70,6 +74,7 @@ import {
type BrowserToolName,
type BrowserToolService
} from '../browser/browser-model-tools'
import type { BrowserTabUsageLease } from '../browser/browser-service'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
import { safeToolErrorDetail } from './approval-summary'
@@ -185,6 +190,8 @@ type Capability = {
configWorkspacePath?: string
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
browserConversationId?: string
browserTabId?: BrowserTabId
browserUsageLease?: BrowserTabUsageLease
expiresAt: number
signal: AbortSignal
brokerController: AbortController
@@ -230,7 +237,14 @@ export type KnowledgeMcpGatewayOptions = {
now?: () => number
magicNotesDatabase?: MagicNotesDatabase
configService?: GoodBuddyConfigService
browserService?: BrowserToolService
browserService?: BrowserToolService & {
listTabs(conversationId: string): BrowserTabSummary[]
acquireTabUsage(
conversationId: string,
tabId: BrowserTabId,
owner: string
): BrowserTabUsageLease
}
launchEnvironmentProvider?: LaunchEnvironmentProvider
}
@@ -356,7 +370,14 @@ export class KnowledgeMcpGateway {
private readonly maximumBodyBytes: number
private readonly magicNotesDatabase?: MagicNotesDatabase
private readonly configService?: GoodBuddyConfigService
private readonly browserService?: BrowserToolService
private readonly browserService?: BrowserToolService & {
listTabs(conversationId: string): BrowserTabSummary[]
acquireTabUsage(
conversationId: string,
tabId: BrowserTabId,
owner: string
): BrowserTabUsageLease
}
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
private server?: Server
private endpoint?: string
@@ -432,7 +453,9 @@ export class KnowledgeMcpGateway {
workspacePath: string
authorizeApply?: GoodBuddyConfigApplyAuthorizer
},
browserConversationId?: string
browserConversationId?: string,
browserTabId?: BrowserTabId,
browserUsageLease?: BrowserTabUsageLease
): string | undefined {
const effectiveMagicNotesAccess = this.magicNotesDatabase
? magicNotesAccess
@@ -443,6 +466,39 @@ export class KnowledgeMcpGateway {
const effectiveBrowserConversationId = this.browserService
? browserConversationId
: undefined
const effectiveBrowserTabId = effectiveBrowserConversationId
? browserTabId
: undefined
if (
effectiveBrowserConversationId &&
(!effectiveBrowserTabId ||
!this.browserService!
.listTabs(effectiveBrowserConversationId)
.some((tab) => tab.tabId === effectiveBrowserTabId))
) {
throw new Error('浏览器标签页不存在或不属于当前对话')
}
let effectiveBrowserUsageLease: BrowserTabUsageLease | undefined
if (effectiveBrowserConversationId && effectiveBrowserTabId) {
effectiveBrowserUsageLease = browserUsageLease ??
this.browserService!.acquireTabUsage(
effectiveBrowserConversationId,
effectiveBrowserTabId,
requestId
)
if (
effectiveBrowserUsageLease.conversationId !==
effectiveBrowserConversationId ||
effectiveBrowserUsageLease.tabId !== effectiveBrowserTabId ||
effectiveBrowserUsageLease.owner !== requestId
) {
effectiveBrowserUsageLease.release()
throw new Error('浏览器标签页使用租约与当前请求不匹配')
}
} else if (browserUsageLease) {
browserUsageLease.release()
throw new Error('浏览器标签页使用租约与当前请求不匹配')
}
if (
authorizedLibraryIds.length === 0 &&
effectiveMagicNotesAccess === 'none' &&
@@ -452,21 +508,28 @@ export class KnowledgeMcpGateway {
return undefined
}
signal.throwIfAborted()
return this.storeCapability({
requestId,
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
magicNotesAccess: effectiveMagicNotesAccess,
configAccess: effectiveConfigAccess,
browserConversationId: effectiveBrowserConversationId,
...(effectiveConfigAccess !== 'none'
? {
configWorkspacePath: config?.workspacePath,
authorizeConfigApply: config?.authorizeApply
}
: {}),
signal,
customMcpServers: []
})
try {
return this.storeCapability({
requestId,
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
magicNotesAccess: effectiveMagicNotesAccess,
configAccess: effectiveConfigAccess,
browserConversationId: effectiveBrowserConversationId,
browserTabId: effectiveBrowserTabId,
browserUsageLease: effectiveBrowserUsageLease,
...(effectiveConfigAccess !== 'none'
? {
configWorkspacePath: config?.workspacePath,
authorizeConfigApply: config?.authorizeApply
}
: {}),
signal,
customMcpServers: []
})
} catch (error) {
effectiveBrowserUsageLease?.release()
throw error
}
}
grantCustomMcp(
@@ -517,14 +580,27 @@ export class KnowledgeMcpGateway {
const abort = (): void => {
this.revoke(token)
}
const abortFromBrowser = (): void => {
this.revoke(token)
}
value.signal.addEventListener('abort', abort, { once: true })
value.browserUsageLease?.signal.addEventListener(
'abort',
abortFromBrowser,
{ once: true }
)
this.capabilities.set(token, {
...value,
expiresAt: this.now() + this.capabilityTtlMs,
brokerController,
references: new Map(),
removeAbortListener: () =>
removeAbortListener: () => {
value.signal.removeEventListener('abort', abort)
value.browserUsageLease?.signal.removeEventListener(
'abort',
abortFromBrowser
)
}
})
return token
}
@@ -542,6 +618,7 @@ export class KnowledgeMcpGateway {
capability.brokerController.abort(
new Error('MCP capability was revoked')
)
capability.browserUsageLease?.release()
for (const session of this.downstreamMcpSessions.values()) {
if (session.token === token) {
void this.closeDownstreamMcpSession(session)
@@ -724,7 +801,7 @@ export class KnowledgeMcpGateway {
...(capability.configAccess === 'write'
? goodbuddyConfigWriteToolNames
: []),
...(capability.browserConversationId
...(capability.browserConversationId && capability.browserTabId
? browserToolNames
: [])
]
@@ -1518,10 +1595,11 @@ export class KnowledgeMcpGateway {
}
)
const capability = this.getCapability(token)
const browserTools = capability.browserConversationId
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
const browserDefinitions = browserTools
@@ -1579,10 +1657,11 @@ export class KnowledgeMcpGateway {
}
}
const capability = this.getCapability(token)
const browserTools = capability.browserConversationId
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
if (browserTools?.ownsTool(name)) {
+34
View File
@@ -18,6 +18,40 @@ const toolPng = Buffer.from([
0x0d, 0x0a, 0x1a, 0x0a
]).toString('base64')
it('preserves full supplied and cached history beyond message and byte caps', async () => {
const fetcher = vi.fn<typeof fetch>(async () => new Response(
createEventStream('reply'),
{ headers: { 'content-type': 'text/event-stream' } }
))
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://example.test',
model: 'test-model',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
const history = Array.from({ length: 502 }, (_, index) => ({
role: index % 2 ? 'assistant' as const : 'user' as const,
content: `${index}: ${'x'.repeat(4_200)}`
}))
for (const suppliedHistory of [history, undefined]) {
for await (const event of runtime.run({
requestId: crypto.randomUUID(),
conversationId: 'full-history',
prompt: 'next',
history: suppliedHistory
}, new AbortController().signal)) {
expect(event).toBeDefined()
}
}
for (const [, init] of fetcher.mock.calls) {
const body = JSON.parse(String(init?.body))
expect(body.messages.slice(0, history.length)).toEqual(history)
}
expect(fetcher).toHaveBeenCalledTimes(2)
})
function createTextToolResult(text: string): ModelToolResult {
return {
parts: [{ type: 'text', text }],
+3 -20
View File
@@ -2625,24 +2625,8 @@ export class ModelAgentRuntime implements AgentRuntime {
conversationId: string,
messages: ConversationMessage[]
): void {
const retained: ConversationMessage[] = []
let bytes = 0
const compressionEnabled =
this.options.contextCompression?.settings.enabled === true
const maximumMessages = compressionEnabled ? 500 : 20
const maximumBytes = compressionEnabled
? 2 * 1024 * 1024
: 512 * 1024
for (const message of messages.slice(-maximumMessages).reverse()) {
const messageBytes = Buffer.byteLength(message.content)
if (bytes + messageBytes > maximumBytes) {
break
}
retained.unshift(message)
bytes += messageBytes
}
this.conversations.delete(conversationId)
this.conversations.set(conversationId, retained)
this.conversations.set(conversationId, [...messages])
while (this.conversations.size > 50) {
const oldest = this.conversations.keys().next().value
if (oldest) {
@@ -2658,9 +2642,7 @@ export class ModelAgentRuntime implements AgentRuntime {
request.history && request.history.length > 0
? request.history
: this.conversations.get(request.conversationId) ?? []
return this.options.contextCompression?.settings.enabled
? history
: history.slice(-20)
return history
}
private async *runImageGeneration(
@@ -3381,6 +3363,7 @@ export class ModelAgentRuntime implements AgentRuntime {
}
const toolContext: ModelToolCallContext = {
conversationId: request.conversationId,
browserTabId: request.browserTabId,
workMode: request.workMode ?? 'ask',
requestId: request.requestId,
runtimeTarget: 'model',
+291 -82
View File
@@ -7,9 +7,11 @@ import {
} 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 { browserTabIdSchema } from '../../shared/contracts'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
import {
LocalWorkspaceAccess,
@@ -64,8 +66,15 @@ const png = Buffer.from([
0x0d, 0x0a, 0x1a, 0x0a
]).toString('base64')
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]).toString('base64')
const firstBrowserTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000201'
)
const secondBrowserTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000202'
)
const toolContext = {
conversationId: 'provider-test-conversation',
browserTabId: firstBrowserTabId,
workMode: 'execute'
} satisfies ModelToolCallContext
@@ -177,18 +186,159 @@ describe('ModelToolProvider', () => {
await provider.dispose()
})
it('provides bounded workspace read, list, and atomic write tools', async () => {
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 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).toHaveBeenCalledOnce()
} finally {
await provider.dispose()
warning.mockRestore()
}
}
)
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 {
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' })
])
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')
const provider = new ModelToolProvider(workspace)
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_list_directory' }),
expect.objectContaining({ name: 'workspace_write_text' })
expect.objectContaining({ name: 'workspace_apply_patch' })
])
)
await expect(
@@ -198,32 +348,50 @@ describe('ModelToolProvider', () => {
signal,
toolContext
)
).resolves.toEqual({
parts: [{ type: 'text', text: 'hello' }],
contextBytes: 5
).resolves.toMatchObject({
parts: [{ type: 'text', text: expect.stringContaining('1: hello') }]
})
const listing = await provider.callTool(
'workspace_list_directory',
{ path: 'docs' },
'workspace_rg',
{ path: 'docs', filesOnly: true },
signal,
toolContext
)
expect(listing.parts).toEqual([
expect.objectContaining({
type: 'text',
text: expect.stringContaining('"note.txt"')
text: expect.stringContaining('docs/note.txt')
})
])
const matches = await provider.callTool(
'workspace_rg',
{ path: 'docs', pattern: 'target' },
signal,
toolContext
)
expect(matches.parts).toEqual([
expect.objectContaining({
type: 'text',
text: expect.stringContaining('docs/search.txt:1:2:中target')
})
])
const written = await provider.callTool(
'workspace_write_text',
{ path: 'docs/output.txt', content: 'saved' },
'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('"bytesWritten":5')
text: expect.stringContaining('docs/output.txt')
})
])
await expect(
@@ -231,12 +399,15 @@ describe('ModelToolProvider', () => {
).resolves.toBe('saved')
})
it('delegates workspace tools and disposal to WorkspaceAccess', async () => {
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
size: 14,
offsetBytes: 0,
bytesRead: 14,
truncated: false
}))
const listDirectory = vi.fn(async () => ({
path: '',
@@ -270,43 +441,19 @@ describe('ModelToolProvider', () => {
signal,
toolContext
)
).resolves.toEqual({
parts: [{ type: 'text', text: 'remote content' }],
contextBytes: 14
).resolves.toMatchObject({
parts: [{ type: 'text', text: expect.stringContaining('1: remote content') }]
})
await provider.callTool(
'workspace_list_directory',
{},
signal,
toolContext
)
await provider.callTool(
'workspace_write_text',
{ path: 'output.txt', content: 'saved' },
signal,
toolContext
)
expect(readText).toHaveBeenCalledWith(
expect.objectContaining({
path: 'remote.txt',
maximumBytes: 256 * 1024,
maximumBytes: 128 * 1024,
allowTruncated: true,
signal
})
)
expect(listDirectory).toHaveBeenCalledWith(
expect.objectContaining({
path: '.',
maximumEntries: 200,
includeOther: true,
signal
})
)
expect(writeTextAtomic).toHaveBeenCalledWith({
path: 'output.txt',
content: 'saved',
maximumBytes: 512 * 1024,
signal
})
expect(listDirectory).not.toHaveBeenCalled()
expect(writeTextAtomic).not.toHaveBeenCalled()
await provider.dispose()
expect(dispose).toHaveBeenCalledOnce()
})
@@ -361,8 +508,8 @@ describe('ModelToolProvider', () => {
for (const [name, argumentsValue] of [
[
'workspace_write_text',
{ path: 'output.txt', content: 'blocked' }
'workspace_apply_patch',
{ patch: '*** Begin Patch\n*** End Patch' }
],
['browser_click', { ref: 'b_target' }],
['note_create', { title: 'blocked' }],
@@ -389,8 +536,8 @@ describe('ModelToolProvider', () => {
expect(mocks.Client).not.toHaveBeenCalled()
const writeTool = {
name: 'workspace_write_text',
displayName: '写入工作区文本',
name: 'workspace_apply_patch',
displayName: '应用工作区补丁',
description: 'write',
inputSchema: {},
source: 'builtin'
@@ -398,7 +545,7 @@ describe('ModelToolProvider', () => {
expect(() =>
provider.getApproval(
writeTool,
{ path: 'output.txt', content: 'blocked' },
{ patch: '*** Begin Patch\n*** End Patch' },
'output.txt',
askContext
)
@@ -425,20 +572,10 @@ describe('ModelToolProvider', () => {
askContext
)
).resolves.toMatchObject({
parts: [{ type: 'text', text: 'remote content' }]
})
await expect(
provider.callTool(
'workspace_list_directory',
{},
signal,
askContext
)
).resolves.toMatchObject({
parts: [{ type: 'text', text: expect.any(String) }]
parts: [{ type: 'text', text: expect.stringContaining('1: remote content') }]
})
expect(readText).toHaveBeenCalledOnce()
expect(listDirectory).toHaveBeenCalledOnce()
expect(listDirectory).not.toHaveBeenCalled()
})
it('exposes scoped reads in Ask and Magic Notes writes only in Execute', async () => {
@@ -499,6 +636,7 @@ describe('ModelToolProvider', () => {
const askTools = await provider.listTools(askContext, signal)
expect(askTools.map((tool) => tool.name)).toEqual([
'workspace_read_text',
'knowledge_list',
'knowledge_search',
'note_list',
@@ -578,7 +716,11 @@ describe('ModelToolProvider', () => {
},
signal
)
).resolves.toEqual([])
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'workspace_read_text' })
])
)
const executeTools = await provider.listTools(
{ ...askContext, workMode: 'execute' },
signal
@@ -586,8 +728,7 @@ describe('ModelToolProvider', () => {
expect(executeTools.map((tool) => tool.name)).toEqual(
expect.arrayContaining([
'workspace_read_text',
'workspace_list_directory',
'workspace_write_text',
'workspace_apply_patch',
'knowledge_list',
'knowledge_search',
'note_search',
@@ -705,7 +846,9 @@ describe('ModelToolProvider', () => {
workspace,
[createMcpServer()],
undefined,
gateway
gateway,
false,
{ ripgrepExecutablePath: rgPath }
)
await expect(
validProvider.listTools(context, new AbortController().signal)
@@ -719,14 +862,16 @@ describe('ModelToolProvider', () => {
workspace,
[createMcpServer()],
undefined,
gateway
gateway,
false,
{ ripgrepExecutablePath: rgPath }
)
await expect(
overflowingProvider.listTools(
context,
new AbortController().signal
)
).rejects.toThrow('无法加载 MCP Server')
).resolves.toHaveLength(18)
await overflowingProvider.dispose()
})
@@ -799,6 +944,7 @@ describe('ModelToolProvider', () => {
stdoutTruncated: false,
stderrTruncated: false
})),
readOutput: vi.fn(),
releaseConversation: vi.fn(async () => undefined),
dispose: vi.fn(async () => undefined)
} satisfies DirectModelProcessService
@@ -994,10 +1140,12 @@ describe('ModelToolProvider', () => {
const provider = new ModelToolProvider(workspace, [], browserService)
const firstContext = {
conversationId: 'browser-conversation-one',
browserTabId: firstBrowserTabId,
workMode: 'execute'
} satisfies ModelToolCallContext
const secondContext = {
conversationId: 'browser-conversation-two',
browserTabId: secondBrowserTabId,
workMode: 'execute'
} satisfies ModelToolCallContext
const signal = new AbortController().signal
@@ -1051,12 +1199,14 @@ describe('ModelToolProvider', () => {
expect(browserService.screenshot).toHaveBeenNthCalledWith(
1,
firstContext.conversationId,
signal
signal,
firstBrowserTabId
)
expect(browserService.screenshot).toHaveBeenNthCalledWith(
2,
secondContext.conversationId,
signal
signal,
secondBrowserTabId
)
await provider.releaseConversation(firstContext.conversationId)
@@ -1090,6 +1240,63 @@ describe('ModelToolProvider', () => {
})
})
it('keeps a request on its bound tab when another tab changes or closes', async () => {
const workspace = await createWorkspace()
const browserService = createBrowserService()
const provider = new ModelToolProvider(workspace, [], browserService)
const conversationId = 'two-tab-conversation'
const firstRequest = {
conversationId,
browserTabId: firstBrowserTabId,
workMode: 'execute',
requestId: 'first-tab-request'
} satisfies ModelToolCallContext
const secondRequest = {
conversationId,
browserTabId: secondBrowserTabId,
workMode: 'execute',
requestId: 'second-tab-request'
} satisfies ModelToolCallContext
const signal = new AbortController().signal
await provider.callTool('browser_snapshot', {}, signal, firstRequest)
await provider.callTool('browser_snapshot', {}, signal, secondRequest)
expect(browserService.snapshot).toHaveBeenNthCalledWith(
1,
conversationId,
signal,
firstBrowserTabId
)
expect(browserService.snapshot).toHaveBeenNthCalledWith(
2,
conversationId,
signal,
secondBrowserTabId
)
vi.mocked(browserService.click).mockImplementation(
async (_conversationId, _ref, _signal, tabId) => {
if (tabId === firstBrowserTabId) {
throw new Error('浏览器标签页不存在或不属于当前对话')
}
}
)
await expect(
provider.callTool(
'browser_click',
{ ref: 'b_closedTabReference' },
signal,
firstRequest
)
).rejects.toThrow('浏览器标签页不存在或不属于当前对话')
expect(browserService.click).toHaveBeenCalledWith(
conversationId,
'b_closedTabReference',
signal,
firstBrowserTabId
)
})
it('exposes only allowlisted read-only Exa tools in Ask and Execute', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockResolvedValue({
@@ -1130,18 +1337,20 @@ describe('ModelToolProvider', () => {
workMode: 'ask'
} satisfies ModelToolCallContext
await expect(provider.listTools(askContext, signal)).resolves.toEqual([
expect.objectContaining({
name: 'web_search',
displayName: '联网搜索',
source: 'builtin'
}),
expect.objectContaining({
name: 'web_fetch',
displayName: '读取网页',
source: 'builtin'
})
])
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 },
+43 -32
View File
@@ -24,6 +24,7 @@ import type {
} from './runtime'
import {
BrowserModelTools,
browserToolNames,
type BrowserToolService
} from '../browser/browser-model-tools'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
@@ -45,12 +46,12 @@ import {
directModelSubagentInputSchema
} from '../assistant/direct-model-subagent-service'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
import type { BrowserTabId } from '../../shared/contracts'
import { searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
import { applyWorkspacePatch } from './workspace-apply-patch'
import { readWorkspaceLines } from './workspace-read-lines'
const MAX_MODEL_TOOLS = 100
const MAX_MCP_SERVERS = 16
const MAX_TOOL_RESULT_BYTES = 256 * 1024
const MCP_TIMEOUT_MS = 30_000
const MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
@@ -269,6 +270,7 @@ export type ModelToolResult = {
export type ModelToolCallContext = {
conversationId: string
browserTabId?: BrowserTabId
workMode: 'ask' | 'execute'
requestId?: string
runtimeTarget?: 'model'
@@ -635,7 +637,10 @@ function normalizeMcpResult(result: unknown): ModelToolResult {
}
export class ModelToolProvider implements ModelToolProviderLike {
private mcpConnections?: Promise<ConnectedMcp[]>
private readonly mcpConnections = new Map<
ResolvedMcpServer,
Promise<ConnectedMcp | undefined>
>()
private webSearchBindings?: Promise<Map<string, McpToolBinding>>
private readonly clients = new Set<Client>()
private readonly customMcpClients = new Set<Client>()
@@ -708,10 +713,13 @@ export class ModelToolProvider implements ModelToolProviderLike {
private getBrowserTools(
context: ModelToolCallContext
): BrowserModelTools | undefined {
return this.browserService && context.workMode === 'execute'
return this.browserService &&
context.workMode === 'execute' &&
context.browserTabId
? new BrowserModelTools({
service: this.browserService,
conversationId: context.conversationId
conversationId: context.conversationId,
browserTabId: context.browserTabId
})
: undefined
}
@@ -719,7 +727,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
private getReservedToolCount(): number {
return (
this.getBuiltinTools().length +
(this.browserService ? 7 : 0) +
(this.browserService ? browserToolNames.length : 0) +
(this.webSearchEnabled ? 2 : 0) +
(this.programming.processService ? 1 : 0) +
(this.programming.subagentService ? 1 : 0) +
@@ -1077,23 +1085,27 @@ export class ModelToolProvider implements ModelToolProviderLike {
signal: AbortSignal,
refreshDynamic = false
): Promise<Map<string, McpToolBinding>> {
if (this.mcpServers.length > MAX_MCP_SERVERS) {
throw new Error('直连模型最多可加载 16 个 MCP Server')
}
this.mcpConnections ??= Promise.all(
this.mcpServers.map((server) => this.connectMcpServer(server, signal))
)
.catch(async (error) => {
this.mcpConnections = undefined
const clients = [...this.customMcpClients]
this.customMcpClients.clear()
clients.forEach((client) => this.clients.delete(client))
await Promise.allSettled(
clients.map((client) => client.close())
)
throw error
signal.throwIfAborted()
const loaded = await Promise.all(
this.mcpServers.map((server) => {
let pending = this.mcpConnections.get(server)
if (!pending) {
pending = this.connectMcpServer(server, signal).catch((error) => {
this.mcpConnections.delete(server)
if (!signal.aborted) {
console.warn(`MCP discovery failed: ${server.name}`, error)
}
return undefined
})
this.mcpConnections.set(server, pending)
}
return pending
})
const connections = await this.mcpConnections
)
signal.throwIfAborted()
const connections = loaded.filter(
(connection): connection is ConnectedMcp => connection !== undefined
)
if (refreshDynamic) {
await Promise.all(
connections.map(async (connection) => {
@@ -1123,14 +1135,18 @@ export class ModelToolProvider implements ModelToolProviderLike {
)
} catch (error) {
connection.dynamicToolsChanged = true
throw new Error(
`无法刷新 MCP Server「${connection.server.name}」的工具`,
{ cause: error }
)
connection.tools = []
if (!signal.aborted) {
console.warn(
`MCP refresh failed: ${connection.server.name}`,
error
)
}
}
})
)
}
signal.throwIfAborted()
const bindings = new Map<string, McpToolBinding>()
const reservedToolCount = this.getReservedToolCount()
for (const connection of connections) {
@@ -1782,7 +1798,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
this.clients.clear()
this.customMcpClients.clear()
this.webSearchClients.clear()
this.mcpConnections = undefined
this.mcpConnections.clear()
this.webSearchBindings = undefined
await Promise.allSettled([
...clients.map((client) => client.close()),
@@ -1794,12 +1810,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
async releaseConversation(conversationId: string): Promise<void> {
await Promise.allSettled([
this.browserService
? new BrowserModelTools({
service: this.browserService,
conversationId
}).release()
: undefined,
this.browserService?.releaseConversation(conversationId),
this.programming.processService?.releaseConversation(
conversationId
),
+104
View File
@@ -0,0 +1,104 @@
import { mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { PagedOutputStore, PAGED_OUTPUT_MAX_PAGE_BYTES } from './paged-output-store'
const stores: PagedOutputStore[] = []
const directories: string[] = []
afterEach(async () => {
await Promise.all(stores.splice(0).map((store) => store.dispose()))
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})
async function createStore() {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-output-test-'))
directories.push(directory)
const store = new PagedOutputStore('test', { temporaryParentDirectory: directory })
stores.push(store)
return { store, directory }
}
describe('PagedOutputStore', () => {
it.each([1, 2, 3, 4, 7])('round trips split UTF-8 chunks with a %i byte page', async (limit) => {
const { store } = await createStore()
const text = 'a\u4f60\ud83d\ude00\ufffd\u597dZ'
const writer = await store.create('owner', 2)
for (const byte of Buffer.from(text)) writer.append(Buffer.from([byte]))
const saved = await writer.finish()
expect(saved.text).toBe('a')
let output = saved.text
let cursor = saved.reference!.nextCursor
while (cursor < saved.reference!.totalBytes) {
const page = await store.read('owner', writer.handle, cursor, limit)
expect(page.nextCursor).toBeGreaterThan(cursor)
expect(Buffer.byteLength(page.content)).toBeLessThanOrEqual(Math.max(limit, 4))
output += page.content
cursor = page.nextCursor
}
expect(output).toBe(text)
await expect(store.read('owner', writer.handle, cursor, limit)).resolves.toMatchObject({ content: '', eof: true, nextCursor: cursor })
await expect(store.read('owner', writer.handle, 2)).rejects.toThrow('UTF-8')
await expect(store.read('other', writer.handle)).rejects.toThrow()
for (const invalid of [-1, 0.5, cursor + 1]) {
await expect(store.read('owner', writer.handle, invalid)).rejects.toThrow()
}
for (const invalid of [0, 0.5, PAGED_OUTPUT_MAX_PAGE_BYTES + 1]) {
await expect(store.read('owner', writer.handle, 0, invalid)).rejects.toThrow()
}
})
it('keeps literal replacement characters in previews', async () => {
const { store } = await createStore()
const writer = await store.create('owner', 3)
writer.append('\ufffdrest')
const saved = await writer.finish()
expect(saved.text).toBe('\ufffd')
expect(saved.reference?.nextCursor).toBe(3)
})
it('retains output omitted to keep JSON-escaped previews bounded', async () => {
const { store } = await createStore()
const writer = await store.create('owner', 12)
writer.append('\u0000'.repeat(12))
const saved = await writer.finish()
expect(saved.text).toBe('\u0000\u0000')
expect(saved.reference?.nextCursor).toBe(2)
const page = await store.read('owner', writer.handle, saved.reference!.nextCursor)
expect(saved.text + page.content).toBe('\u0000'.repeat(12))
})
it('removes small, aborted, released and disposed output files', async () => {
const { store, directory } = await createStore()
const small = await store.create('owner', 10)
small.append('small')
expect(await small.finish()).toEqual({ text: 'small', truncated: false })
const retained = await store.create('owner', 1)
retained.append('retained')
await retained.finish()
const active = await store.create('owner', 1)
active.append('active')
const other = await store.create('other', 1)
other.append('other')
await other.finish()
const [childDirectory] = await readdir(directory)
await store.releaseOwner('owner')
expect(await readdir(join(directory, childDirectory!))).toHaveLength(1)
await expect(active.finish()).rejects.toThrow()
await expect(store.read('owner', retained.handle)).rejects.toThrow()
await expect(store.read('other', other.handle)).resolves.toMatchObject({ content: 'other' })
await store.create('other', 1)
await store.dispose()
expect(await readdir(directory)).toEqual([])
await expect(store.create('owner', 1)).rejects.toThrow()
})
it('cleans up when disposed during directory creation', async () => {
const { store, directory } = await createStore()
const pending = store.create('owner', 1)
const rejected = expect(pending).rejects.toThrow()
await store.dispose()
await rejected
expect(await readdir(directory)).toEqual([])
})
})
+296
View File
@@ -0,0 +1,296 @@
import { randomUUID } from 'node:crypto'
import {
closeSync,
openSync,
readSync,
writeSync
} from 'node:fs'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
export const PAGED_OUTPUT_DEFAULT_PAGE_BYTES = 32 * 1024
export const PAGED_OUTPUT_MAX_PAGE_BYTES = 32 * 1024
export type PagedOutputReference = {
handle: string
nextCursor: number
totalBytes: number
}
export type PagedOutputPage = {
handle: string
content: string
cursor: number
nextCursor: number
totalBytes: number
eof: boolean
}
type OutputEntry = {
ownerId: string
path: string
totalBytes: number
}
export type PagedOutputStoreOptions = {
temporaryParentDirectory?: string
}
export class PagedOutputWriter {
private preview = Buffer.alloc(0)
private totalBytes = 0
private failure?: Error
private closed = false
constructor(
private readonly store: PagedOutputStore,
readonly handle: string,
readonly ownerId: string,
readonly path: string,
private readonly descriptor: number,
private readonly previewBytes: number
) {}
append(chunk: Buffer | string): void {
if (this.closed || this.failure) {
return
}
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
try {
let offset = 0
while (offset < value.byteLength) {
const written = writeSync(this.descriptor, value, offset)
if (written === 0) throw new Error('Unable to write tool output')
offset += written
}
this.totalBytes += value.byteLength
if (this.preview.byteLength < this.previewBytes) {
const remaining = this.previewBytes - this.preview.byteLength
this.preview = Buffer.concat([
this.preview,
value.subarray(0, remaining)
])
}
} catch (error) {
this.failure =
error instanceof Error ? error : new Error('无法保存分页输出')
}
}
async finish(): Promise<{
text: string
truncated: boolean
reference?: PagedOutputReference
}> {
if (this.closed) {
throw new Error('分页输出已经关闭')
}
this.closed = true
closeSync(this.descriptor)
if (this.failure) {
await this.store.discard(this)
throw new Error('无法保存完整工具输出', { cause: this.failure })
}
let previewLength = utf8PrefixLength(this.preview)
let text = this.preview.subarray(0, previewLength).toString('utf8')
// Tool results are JSON: escaped control characters also consume context.
if (Buffer.byteLength(JSON.stringify(text)) - 2 > this.previewBytes) {
let low = 0
let high = previewLength
while (low < high) {
const middle = Math.ceil((low + high) / 2)
const candidate = utf8Prefix(this.preview.subarray(0, middle))
if (Buffer.byteLength(JSON.stringify(candidate)) - 2 <= this.previewBytes) {
low = middle
} else {
high = middle - 1
}
}
previewLength = utf8PrefixLength(this.preview, low)
text = this.preview.subarray(0, previewLength).toString('utf8')
}
if (this.totalBytes <= previewLength) {
await this.store.discard(this)
return { text, truncated: false }
}
this.store.retain(this, this.totalBytes)
return {
text,
truncated: true,
reference: {
handle: this.handle,
nextCursor: previewLength,
totalBytes: this.totalBytes
}
}
}
async abort(): Promise<void> {
if (!this.closed) {
this.closed = true
closeSync(this.descriptor)
}
await this.store.discard(this)
}
}
function utf8PrefixLength(value: Buffer, end = value.byteLength): number {
if (end === 0) return 0
let start = end - 1
while (start > 0 && (value[start]! & 0xc0) === 0x80) start -= 1
const lead = value[start]!
const width = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1
return start + width > end ? start : end
}
function utf8Prefix(value: Buffer): string {
return value.subarray(0, utf8PrefixLength(value)).toString('utf8')
}
export class PagedOutputStore {
private readonly entries = new Map<string, OutputEntry>()
private readonly writers = new Set<PagedOutputWriter>()
private directoryPromise?: Promise<string>
private disposed = false
private disposePromise?: Promise<void>
constructor(
private readonly handlePrefix: string,
private readonly options: PagedOutputStoreOptions = {}
) {}
async create(
ownerId: string,
previewBytes: number
): Promise<PagedOutputWriter> {
if (this.disposed) {
throw new Error('分页输出存储已关闭')
}
const directory = await this.getDirectory()
if (this.disposed) throw new Error('分页输出存储已关闭')
const id = randomUUID()
const path = join(directory, id)
const descriptor = openSync(path, 'wx')
const writer = new PagedOutputWriter(
this,
`${this.handlePrefix}:${id}`,
ownerId,
path,
descriptor,
previewBytes
)
this.writers.add(writer)
return writer
}
async read(
ownerId: string,
handle: string,
cursor = 0,
limitBytes = PAGED_OUTPUT_DEFAULT_PAGE_BYTES
): Promise<PagedOutputPage> {
const entry = this.entries.get(handle)
if (!entry || entry.ownerId !== ownerId) {
throw new Error('分页输出句柄不存在或不属于当前会话')
}
if (!Number.isSafeInteger(cursor) || cursor < 0 || cursor > entry.totalBytes) {
throw new Error('分页输出 cursor 无效')
}
if (
!Number.isSafeInteger(limitBytes) ||
limitBytes < 1 ||
limitBytes > PAGED_OUTPUT_MAX_PAGE_BYTES
) {
throw new Error(`分页输出每次最多读取 ${PAGED_OUTPUT_MAX_PAGE_BYTES} 字节`)
}
const requestedBytes = Math.min(limitBytes + 3, entry.totalBytes - cursor)
const buffer = Buffer.alloc(requestedBytes)
const descriptor = openSync(entry.path, 'r')
let bytesRead: number
try {
bytesRead = readSync(descriptor, buffer, 0, requestedBytes, cursor)
} finally {
closeSync(descriptor)
}
if (bytesRead && (buffer[0]! & 0xc0) === 0x80) {
throw new Error('分页输出 cursor 必须位于 UTF-8 字符边界')
}
let consumedBytes = utf8PrefixLength(buffer, Math.min(limitBytes, bytesRead))
// A tiny page still consumes one complete character (at most four bytes).
if (consumedBytes === 0 && bytesRead > 0) {
consumedBytes = utf8PrefixLength(buffer, Math.min(4, bytesRead))
}
// Preserve forward progress even for an incomplete final UTF-8 sequence.
if (consumedBytes === 0) consumedBytes = bytesRead
const content = buffer.subarray(0, consumedBytes).toString('utf8')
const nextCursor = cursor + consumedBytes
return {
handle,
content,
cursor,
nextCursor,
totalBytes: entry.totalBytes,
eof: nextCursor >= entry.totalBytes
}
}
async releaseOwner(ownerId: string): Promise<void> {
await Promise.all([...this.writers]
.filter((writer) => writer.ownerId === ownerId)
.map((writer) => writer.abort()))
const owned = [...this.entries].filter(
([, entry]) => entry.ownerId === ownerId
)
for (const [handle] of owned) {
this.entries.delete(handle)
}
await Promise.allSettled(
owned.map(([, entry]) => rm(entry.path, { force: true }))
)
}
dispose(): Promise<void> {
this.disposePromise ??= this.disposeOnce()
return this.disposePromise
}
private async disposeOnce(): Promise<void> {
this.disposed = true
await Promise.all([...this.writers].map((writer) => writer.abort()))
this.entries.clear()
if (this.directoryPromise) {
const directory = await this.directoryPromise.catch(() => undefined)
if (directory) {
await rm(directory, { recursive: true, force: true })
}
}
}
retain(writer: PagedOutputWriter, totalBytes: number): void {
this.writers.delete(writer)
this.entries.set(writer.handle, {
ownerId: writer.ownerId,
path: writer.path,
totalBytes
})
}
async discard(writer: PagedOutputWriter): Promise<void> {
this.writers.delete(writer)
this.entries.delete(writer.handle)
await rm(writer.path, { force: true })
}
private getDirectory(): Promise<string> {
this.directoryPromise ??= this.createDirectory()
return this.directoryPromise
}
private async createDirectory(): Promise<string> {
const parent = this.options.temporaryParentDirectory ?? tmpdir()
await mkdir(parent, { recursive: true })
return await mkdtemp(join(parent, `goodbuddy-${this.handlePrefix}-`))
}
}
+1 -1
View File
@@ -229,7 +229,7 @@ describe('runtime selection', () => {
})
).toEqual({
provider: 'opencode',
profileId: secondProfileId
profileId: defaultProfileId
})
expect(
resolveConfiguredAgentRuntimeSelection(configured, {
+7 -2
View File
@@ -49,6 +49,7 @@ export function resolveConfiguredAgentRuntimeSelection(
settings: ResolvedRuntimeSettings,
selection: AgentRuntimeSelection
): AgentRuntimeSelection {
if ('profileId' in selection && selection.profileId) return selection
if (
selection.provider !== 'opencode' &&
selection.provider !== 'continue' &&
@@ -87,7 +88,7 @@ export function applyRuntimeSelection(
}
if (selection.provider === 'model') {
const profile = requireProfile(settings, selection.profileId)
const profile = requireProfile(settings, selection.profileId ?? settings.defaultModelProfileId)
return {
target: 'model',
settings: {
@@ -108,7 +109,11 @@ export function applyRuntimeSelection(
const profile = selection.profileId
? requireProfile(settings, selection.profileId)
: undefined
: selection.provider === 'opencode'
? settings.opencodeModelProfile
: selection.provider === 'continue'
? settings.continueModelProfile
: settings.deepseekHarnessModelProfile
if (selection.provider === 'opencode') {
if (profile && !isAgentRuntimeModelProtocol(profile.protocol)) {
throw new Error(
+3
View File
@@ -4,6 +4,7 @@ import type {
AgentQuestionAnswer,
AgentRequest,
AgentRuntimeStatus,
BrowserTabId,
RuntimeConversationCompactInput,
RuntimeConversationCompactResult,
RuntimeNativeSnapshot
@@ -137,6 +138,8 @@ export type AgentExecutionRequest = Omit<AgentRequest, 'workMode'> & {
trustedInstructions?: string
/** Main-process-only request-scoped authorization for built-in data tools. */
knowledgeCapabilityToken?: string
/** Main-process-only browser tab fixed for this request. */
browserTabId?: BrowserTabId
/**
* Main-process recovery cursor for an Agent-owned semantic prompt.
* It is never sent as prompt content and is ignored by local runtimes.
+43 -6
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { browserTabIdSchema } from '../../shared/contracts'
import {
BrowserModelTools,
browserBackInputSchema,
@@ -42,12 +43,16 @@ function createService(): BrowserToolService {
const signal = new AbortController().signal
const ref = 'b_abcdefghijklmnop'
const browserTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000101'
)
describe('BrowserModelTools', () => {
it('publishes seven strict, bounded builtin tool definitions', () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const definitions = tools.listTools()
expect(definitions.map((definition) => definition.name)).toEqual([
@@ -93,7 +98,8 @@ describe('BrowserModelTools', () => {
it('creates dynamic origin-scoped navigation approvals without exposing query values', () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const approval = tools.getApproval('browser_navigate', {
url: 'https://example.com/path?token=top-secret'
@@ -115,7 +121,8 @@ describe('BrowserModelTools', () => {
const service = createService()
const tools = new BrowserModelTools({
service,
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const first = tools.getApproval('browser_type', {
ref,
@@ -139,7 +146,8 @@ describe('BrowserModelTools', () => {
'conversation',
ref,
'top-secret',
signal
signal,
browserTabId
)
expect(JSON.stringify(result)).not.toContain('top-secret')
expect(result.parts[0]).toMatchObject({
@@ -158,7 +166,8 @@ describe('BrowserModelTools', () => {
const service = createService()
const tools = new BrowserModelTools({
service,
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
const navigate = await tools.callTool(
'browser_navigate',
@@ -189,12 +198,40 @@ describe('BrowserModelTools', () => {
})
await tools.release()
expect(service.releaseConversation).toHaveBeenCalledWith('conversation')
expect(service.navigate).toHaveBeenCalledWith(
'conversation',
'https://example.com/',
signal,
browserTabId
)
expect(service.snapshot).toHaveBeenCalledWith(
'conversation',
signal,
browserTabId
)
expect(service.click).toHaveBeenCalledWith(
'conversation',
ref,
signal,
browserTabId
)
expect(service.back).toHaveBeenCalledWith(
'conversation',
signal,
browserTabId
)
expect(service.screenshot).toHaveBeenCalledWith(
'conversation',
signal,
browserTabId
)
})
it('rejects unknown tools, extra fields, malformed refs, and cancellation', async () => {
const tools = new BrowserModelTools({
service: createService(),
conversationId: 'conversation'
conversationId: 'conversation',
browserTabId
})
expect(() =>
tools.getApproval('browser_click', { ref, extra: true })
+33 -8
View File
@@ -12,6 +12,7 @@ import {
MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH,
MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH
} from './browser-limits'
import type { BrowserTabId } from '../../shared/contracts'
const MAX_REF_LENGTH = 64
@@ -195,6 +196,7 @@ export type BrowserToolService = Pick<
export type BrowserModelToolsOptions = {
service: BrowserToolService
conversationId: string
browserTabId: BrowserTabId
}
function createTextResult(value: unknown): ModelToolResult {
@@ -218,10 +220,12 @@ function navigationLabel(url: URL): string {
export class BrowserModelTools {
private readonly service: BrowserToolService
private readonly conversationId: string
private readonly browserTabId: BrowserTabId
constructor(options: BrowserModelToolsOptions) {
this.service = options.service
this.conversationId = options.conversationId
this.browserTabId = options.browserTabId
if (!this.conversationId || this.conversationId.length > 500) {
throw new Error('浏览器对话标识无效')
}
@@ -249,7 +253,7 @@ export class BrowserModelTools {
throw new Error(`未知浏览器工具:${name}`)
}
const currentOrigin = safeOrigin(
this.service.getOrigin(this.conversationId)
this.service.getOrigin(this.conversationId, this.browserTabId)
)
let description: string
let argumentSummary: string
@@ -320,18 +324,32 @@ export class BrowserModelTools {
if (name === 'browser_navigate') {
const input = browserNavigateInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.navigate(this.conversationId, input.url, signal)
await this.service.navigate(
this.conversationId,
input.url,
signal,
this.browserTabId
)
)
}
if (name === 'browser_snapshot') {
browserSnapshotInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.snapshot(this.conversationId, signal)
await this.service.snapshot(
this.conversationId,
signal,
this.browserTabId
)
)
}
if (name === 'browser_click') {
const input = browserClickInputSchema.parse(argumentsValue)
await this.service.click(this.conversationId, input.ref, signal)
await this.service.click(
this.conversationId,
input.ref,
signal,
this.browserTabId
)
return createTextResult({ clicked: input.ref })
}
if (name === 'browser_type') {
@@ -340,7 +358,8 @@ export class BrowserModelTools {
this.conversationId,
input.ref,
input.text,
signal
signal,
this.browserTabId
)
return createTextResult({
typed: input.ref,
@@ -354,20 +373,26 @@ export class BrowserModelTools {
this.conversationId,
input.ref,
input.value,
signal
signal,
this.browserTabId
)
return createTextResult({ selected: input.ref, value: '[已隐藏]' })
}
if (name === 'browser_back') {
browserBackInputSchema.parse(argumentsValue)
return createTextResult(
await this.service.back(this.conversationId, signal)
await this.service.back(
this.conversationId,
signal,
this.browserTabId
)
)
}
browserScreenshotInputSchema.parse(argumentsValue)
const screenshot = await this.service.screenshot(
this.conversationId,
signal
signal,
this.browserTabId
)
return {
parts: [
+314 -3
View File
@@ -31,6 +31,8 @@ function deferred<T>() {
function createHarness(options: {
maximumSessions?: number
maximumTabsPerConversation?: number
maximumTabsPerWindow?: number
idleTimeoutMs?: number
cleanupTimeoutMs?: number
dispose?: () => Promise<void>
@@ -145,6 +147,8 @@ function createHarness(options: {
const service = new BrowserService({
policy: new BrowserUrlPolicy(dnsResolver),
maximumSessions: options.maximumSessions,
maximumTabsPerConversation: options.maximumTabsPerConversation,
maximumTabsPerWindow: options.maximumTabsPerWindow,
idleTimeoutMs: options.idleTimeoutMs,
cleanupTimeoutMs: options.cleanupTimeoutMs,
liveFrameDelayMs: 0,
@@ -165,12 +169,21 @@ afterEach(() => {
})
describe('BrowserService', () => {
const firstLeaseToken = '7d201980-0ad4-4670-81d4-dc2bf79f03b2'
const secondLeaseToken = 'db8a2c28-43a6-4aab-93e0-1c01b9374dca'
const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251'
it('presents one session in the shared viewport and tracks user navigation', async () => {
const harness = createHarness()
const bounds = { x: 900, y: 120, width: 420, height: 640 }
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
harness.service.setViewport('conversation', bounds)
harness.service.setViewport(
'conversation',
bounds,
undefined,
firstLeaseToken
)
await harness.service.navigate(
'conversation',
@@ -196,7 +209,12 @@ describe('BrowserService', () => {
'https://example.com'
)
harness.service.setViewport()
harness.service.setViewport(
undefined,
undefined,
undefined,
firstLeaseToken
)
expect(slot?.session.setViewport).toHaveBeenLastCalledWith(undefined)
await harness.service.dispose()
})
@@ -222,6 +240,7 @@ describe('BrowserService', () => {
expect(states.map((state) => state.status)).toEqual([
'creating',
'ready',
'loading',
'ready',
'acting',
@@ -242,7 +261,7 @@ describe('BrowserService', () => {
isLoading: false,
canGoBack: false
})
expect(states.find((state) => state.status === 'ready')?.frameDataUrl).toBe(
expect(states.filter((state) => state.status === 'ready').at(-1)?.frameDataUrl).toBe(
'data:image/jpeg;base64,/9j/2Q=='
)
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
@@ -435,6 +454,253 @@ describe('BrowserService', () => {
await harness.service.dispose()
})
it('owns independent tabs inside one conversation and closes only the requested tab', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://first.example/', signal)
const [primary] = harness.service.listTabs('conversation', 11)
const sibling = await harness.service.createTab('conversation', 11, signal)
await harness.service.navigate(
'conversation',
'https://second.example/',
signal,
sibling.tabId,
11
)
await harness.service.snapshot(
'conversation',
signal,
sibling.tabId,
11
)
expect(harness.service.getSessionCount()).toBe(1)
expect(harness.service.getTabCount('conversation')).toBe(2)
expect(harness.slots[0]?.driver.snapshot).not.toHaveBeenCalled()
expect(harness.slots[1]?.driver.snapshot).toHaveBeenCalledOnce()
await harness.service.closeTab('conversation', sibling.tabId, 11)
expect(harness.service.getTabCount('conversation')).toBe(1)
expect(harness.slots[1]?.session.dispose).toHaveBeenCalledOnce()
expect(harness.slots[0]?.session.dispose).not.toHaveBeenCalled()
expect(harness.service.listTabs('conversation', 11)[0]?.tabId).toBe(
primary?.tabId
)
await harness.service.dispose()
})
it('blocks closing a leased tab until every request releases it', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 11, signal)
const sibling = await harness.service.createTab('conversation', 11, signal)
const first = harness.service.acquireTabUsage(
'conversation',
primary.tabId,
'request-one',
11
)
const second = harness.service.acquireTabUsage(
'conversation',
primary.tabId,
'request-two',
11
)
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭')
await expect(
harness.service.closeTab('conversation', sibling.tabId, 11)
).resolves.toBeUndefined()
first.release()
first.release()
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭')
second.release()
await expect(
harness.service.closeTab('conversation', primary.tabId, 11)
).resolves.toBeUndefined()
await harness.service.dispose()
})
it('reports only the visible tab owned by the requesting window', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const sibling = await harness.service.createTab('conversation', 21, signal)
expect(harness.service.getVisibleTabId('conversation', 21)).toBeUndefined()
harness.service.setViewport(
'conversation',
{ x: 0, y: 0, width: 320, height: 480 },
sibling.tabId,
firstLeaseToken,
21
)
expect(harness.service.getVisibleTabId('conversation', 21)).toBe(
sibling.tabId
)
expect(primary.tabId).not.toBe(sibling.tabId)
expect(() => harness.service.getVisibleTabId('conversation', 22)).toThrow(
'不属于当前窗口'
)
await harness.service.dispose()
})
it('aborts active tab leases when a conversation is force-released', async () => {
const harness = createHarness()
const tab = await harness.service.createTab('conversation', 21)
const lease = harness.service.acquireTabUsage(
'conversation',
tab.tabId,
'request',
21
)
await harness.service.releaseConversation('conversation', 21)
expect(lease.signal.aborted).toBe(true)
expect(harness.service.getTabCount('conversation')).toBe(0)
lease.release()
await harness.service.dispose()
})
it('enforces conversation/window ownership, tab limits, and opaque viewport leases', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const sibling = await harness.service.createTab('conversation', 21, signal)
const bounds = { x: 10, y: 20, width: 300, height: 400 }
expect(
harness.service.setViewport(
'conversation',
bounds,
sibling.tabId,
firstLeaseToken,
21
)
).toBe(true)
expect(harness.slots[1]?.session.setViewport).toHaveBeenLastCalledWith(
bounds
)
expect(
harness.service.setViewport(
undefined,
undefined,
undefined,
secondLeaseToken,
21
)
).toBe(false)
expect(harness.slots[1]?.session.setViewport).toHaveBeenLastCalledWith(
bounds
)
await expect(
harness.service.navigate(
'conversation',
'https://example.com/',
signal,
primary.tabId,
22
)
).rejects.toThrow('不属于当前窗口')
await harness.service.dispose()
const conversationLimited = createHarness({
maximumTabsPerConversation: 2
})
await conversationLimited.service.createTab('limited', 31, signal)
await conversationLimited.service.createTab('limited', 31, signal)
await expect(
conversationLimited.service.createTab('limited', 31, signal)
).rejects.toThrow('2 个上限')
await conversationLimited.service.dispose()
const windowLimited = createHarness({ maximumTabsPerWindow: 2 })
await windowLimited.service.createTab('first', 41, signal)
await windowLimited.service.createTab('second', 41, signal)
await expect(
windowLimited.service.createTab('third', 41, signal)
).rejects.toThrow('2 个上限')
await windowLimited.service.dispose()
})
it('returns one live tab for concurrent requests from the same workbar instance', async () => {
const gate = deferred<void>()
const harness = createHarness({ sessionGate: gate.promise })
const signal = new AbortController().signal
const first = harness.service.createTab(
'conversation',
21,
signal,
workbarInstanceId
)
const duplicate = harness.service.createTab(
'conversation',
21,
signal,
workbarInstanceId
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
gate.resolve()
const [firstTab, duplicateTab] = await Promise.all([first, duplicate])
expect(duplicateTab.tabId).toBe(firstTab.tabId)
expect(harness.service.getTabCount('conversation')).toBe(1)
expect(harness.service.getOwnerWindowId('conversation')).toBe(21)
await harness.service.dispose()
})
it('disposes a tab creation that resolves after its context is closed', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const primary = await harness.service.createTab('conversation', 21, signal)
const gate = deferred<void>()
const implementation = harness.createSession.getMockImplementation()!
harness.createSession.mockImplementationOnce(async (...args) => {
await gate.promise
return implementation(...args)
})
const pending = harness.service.createTab(
'conversation',
21,
signal,
'c22e845b-4bc8-4246-9543-dd18dbf20782'
)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledTimes(2))
await harness.service.closeTab('conversation', primary.tabId, 21)
gate.resolve()
await expect(pending).rejects.toThrow()
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.slots[1]?.session.dispose).toHaveBeenCalledOnce()
await harness.service.dispose()
})
it('reserves window capacity while tab contexts are still being created', async () => {
const gate = deferred<void>()
const harness = createHarness({
maximumTabsPerWindow: 1,
sessionGate: gate.promise
})
const signal = new AbortController().signal
const first = harness.service.createTab('first', 51, signal)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
await expect(
harness.service.createTab('second', 51, signal)
).rejects.toThrow('1 个上限')
expect(harness.createSession).toHaveBeenCalledOnce()
gate.resolve()
await first
await harness.service.dispose()
})
it('enforces a hard maximum of three sessions', async () => {
const harness = createHarness({ maximumSessions: 3 })
const signal = new AbortController().signal
@@ -625,6 +891,42 @@ describe('BrowserService', () => {
await harness.service.dispose()
})
it('does not expire a session while a serialized operation is active', async () => {
vi.useFakeTimers()
const harness = createHarness({ idleTimeoutMs: 100 })
const signal = new AbortController().signal
await harness.service.navigate('conversation', 'https://a.example/', signal)
const gate = deferred<void>()
vi.mocked(harness.slots[0]!.driver.click).mockImplementationOnce(async () => gate.promise)
const click = harness.service.click('conversation', 'button_ref', signal)
await vi.advanceTimersByTimeAsync(500)
expect(harness.service.getSessionCount()).toBe(1)
gate.resolve()
await click
await vi.advanceTimersByTimeAsync(101)
expect(harness.service.getSessionCount()).toBe(0)
await harness.service.dispose()
})
it('ignores stale viewport cleanup tokens', async () => {
const harness = createHarness()
const tab = await harness.service.createTab('conversation', 21)
const bounds = { x: 1, y: 2, width: 300, height: 400 }
harness.service.setViewport('conversation', bounds, tab.tabId, firstLeaseToken, 21)
harness.service.setViewport('conversation', bounds, tab.tabId, secondLeaseToken, 21)
expect(
harness.service.setViewport(undefined, undefined, undefined, firstLeaseToken, 21)
).toBe(false)
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(bounds)
expect(
harness.service.setViewport(undefined, undefined, undefined, secondLeaseToken, 21)
).toBe(true)
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(undefined)
await harness.service.dispose()
})
it('tracks an approved origin across validated back navigation', async () => {
const harness = createHarness()
const signal = new AbortController().signal
@@ -905,10 +1207,19 @@ describe('BrowserService', () => {
'https://example.com/',
new AbortController().signal
)
const [tab] = harness.service.listTabs('conversation')
harness.service.setViewport(
'conversation',
{ x: 1, y: 2, width: 300, height: 400 },
tab!.tabId,
firstLeaseToken
)
await expect(
harness.service.releaseConversation('conversation')
).rejects.toThrow('清理超时')
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.service.listTabs('conversation')).toEqual([])
expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(undefined)
await harness.service.releaseConversation('conversation')
await harness.service.dispose()
await harness.service.dispose()
File diff suppressed because it is too large Load Diff
@@ -11,10 +11,12 @@ import {
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
let reject!: (error: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve }
return { promise, resolve, reject }
}
function createHarness() {
@@ -348,6 +350,65 @@ describe('ElectronBrowserSession', () => {
)
})
it('creates sibling views in the same partition and cleans shared state after the final tab', async () => {
const first = createHarness()
const second = createHarness()
const createPartition = vi.fn(async () => first.partition)
const createView = vi
.fn<(options: Record<string, unknown>) => Promise<BrowserViewHandle>>()
.mockResolvedValueOnce(first.window)
.mockResolvedValueOnce(second.window)
const root = await ElectronBrowserSession.create({
policy: first.policy,
createPartition,
createView,
createProxy: () => first.proxy
})
const sibling = await root.createTab(new AbortController().signal)
expect(createPartition).toHaveBeenCalledOnce()
expect(sibling.partition).toBe(root.partition)
expect(
(createView.mock.calls[1]?.[0].webPreferences as Record<string, unknown>)
.partition
).toBe(root.partition)
await root.dispose()
expect(first.partition.clearData).not.toHaveBeenCalled()
expect(first.proxy.dispose).not.toHaveBeenCalled()
await sibling.dispose()
expect(first.partition.clearData).toHaveBeenCalledOnce()
expect(first.partition.closeAllConnections).toHaveBeenCalledOnce()
expect(first.proxy.dispose).toHaveBeenCalledOnce()
})
it('cleans shared state once when sibling creation fails after root disposal', async () => {
const first = createHarness()
const second = createHarness()
const setupGate = deferred<Record<string, never>>()
second.sendCommand.mockImplementationOnce(() => setupGate.promise)
const createView = vi
.fn<(options: Record<string, unknown>) => Promise<BrowserViewHandle>>()
.mockResolvedValueOnce(first.window)
.mockResolvedValueOnce(second.window)
const root = await ElectronBrowserSession.create({
policy: first.policy,
createPartition: async () => first.partition,
createView,
createProxy: () => first.proxy
})
const sibling = root.createTab(new AbortController().signal)
await vi.waitFor(() => expect(second.sendCommand).toHaveBeenCalled())
await root.dispose()
setupGate.reject(new Error('debugger failed'))
await expect(sibling).rejects.toThrow('无法创建浏览器标签页')
expect(first.partition.clearData).toHaveBeenCalledOnce()
expect(first.partition.closeAllConnections).toHaveBeenCalledOnce()
expect(first.proxy.dispose).toHaveBeenCalledOnce()
})
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
const harness = createHarness()
const session = await ElectronBrowserSession.create({
+125 -23
View File
@@ -126,6 +126,30 @@ type Listener = {
listener: BrowserEventListener
}
type SharedBrowserResources = {
partition: string
partitionSession: BrowserPartitionSession
proxy: FilteringProxyLike
createView: (options: Record<string, unknown>) => Promise<BrowserViewHandle>
setupTimeoutMs: number
cleanupTimeoutMs: number
references: number
cleanup?: Promise<void>
}
async function releaseSharedResources(
resources: SharedBrowserResources
): Promise<void> {
resources.references -= 1
if (resources.references !== 0) return
resources.cleanup ??= cleanupIsolatedState(
resources.partitionSession,
resources.proxy,
resources.cleanupTimeoutMs
)
await resources.cleanup
}
function managedBrowserUserAgent(): string {
const platform =
process.platform === 'win32'
@@ -243,14 +267,12 @@ export class ElectronBrowserSession {
private constructor(
private readonly policy: BrowserUrlPolicy,
private readonly partitionSession: BrowserPartitionSession,
private readonly view: BrowserViewHandle,
private readonly proxy: FilteringProxyLike,
partition: string,
private readonly cleanupTimeoutMs: number,
private readonly parentWindow?: BrowserParentWindowHandle
private readonly resources: SharedBrowserResources,
private readonly parentWindow?: BrowserParentWindowHandle,
private readonly listenForPartitionEvents = true
) {
this.partition = partition
this.partition = resources.partition
this.webContents = view.webContents
}
@@ -369,11 +391,16 @@ export class ElectronBrowserSession {
)
result = new ElectronBrowserSession(
options.policy,
partitionSession,
view,
managedProxy,
partition,
cleanupTimeoutMs,
{
partition,
partitionSession,
proxy: managedProxy,
createView,
setupTimeoutMs,
cleanupTimeoutMs,
references: 1
},
options.parentWindow
)
setupStage = '初始化浏览器协议'
@@ -480,14 +507,16 @@ export class ElectronBrowserSession {
this.emitNavigation(url)
}
})
this.listen(
this.partitionSession,
'will-download',
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
event.preventDefault()
item.cancel?.()
}
)
if (this.listenForPartitionEvents) {
this.listen(
this.resources.partitionSession,
'will-download',
(event: { preventDefault(): void }, item: { cancel?(): void }) => {
event.preventDefault()
item.cancel?.()
}
)
}
contents.debugger.attach('1.3')
await contents.debugger.sendCommand('Page.enable')
this.assertOpen()
@@ -625,6 +654,83 @@ export class ElectronBrowserSession {
this.approvedOrigin = target.origin
}
async createTab(signal: AbortSignal): Promise<ElectronBrowserSession> {
this.assertOpen()
const resources = this.resources
resources.references += 1
let view: BrowserViewHandle | undefined
let viewAttached = false
let tab: ElectronBrowserSession | undefined
try {
view = await boundedSetup(
resources.createView({
webPreferences: {
partition: resources.partition,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
nodeIntegrationInWorker: false,
backgroundThrottling: false,
webSecurity: true,
allowRunningInsecureContent: false,
plugins: false,
devTools: false,
safeDialogs: true
}
}),
signal,
resources.setupTimeoutMs,
(lateView) => {
if (!lateView.webContents.isDestroyed()) {
lateView.webContents.close?.({ waitForBeforeUnload: false })
if (!lateView.webContents.isDestroyed()) {
lateView.webContents.destroy()
}
}
}
)
if (this.parentWindow?.contentView && view.nativeView) {
this.parentWindow.contentView.addChildView(view.nativeView)
viewAttached = true
}
if (!view.webContents.loadURL) {
throw new Error('浏览器视图导航不可用')
}
await boundedSetup(
view.webContents.loadURL('about:blank'),
signal,
resources.setupTimeoutMs
)
tab = new ElectronBrowserSession(
this.policy,
view,
resources,
this.parentWindow
)
await boundedSetup(tab.initialize(), signal, resources.setupTimeoutMs)
return tab
} catch (error) {
if (tab) {
await tab.dispose().catch(() => undefined)
} else {
if (view) {
if (viewAttached && view.nativeView) {
this.parentWindow?.contentView?.removeChildView(view.nativeView)
}
if (!view.webContents.isDestroyed()) {
view.webContents.close?.({ waitForBeforeUnload: false })
if (!view.webContents.isDestroyed()) {
view.webContents.destroy()
}
}
}
await releaseSharedResources(resources).catch(() => undefined)
}
throw new Error('无法创建浏览器标签页', { cause: error })
}
}
async dispose(): Promise<void> {
if (this.disposed) {
return
@@ -650,10 +756,6 @@ export class ElectronBrowserSession {
this.webContents.destroy()
}
}
await cleanupIsolatedState(
this.partitionSession,
this.proxy,
this.cleanupTimeoutMs
)
await releaseSharedResources(this.resources)
}
}
+418 -21
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import {
mkdtemp,
readFile,
@@ -13,7 +14,12 @@ import type {
AssistantProject,
ConversationQueueItem
} from '../shared/assistant-contracts'
import type { AgentEvent, BrowserLiveState } from '../shared/contracts'
import {
browserTabIdSchema,
type AgentEvent,
type BrowserLiveState,
type BrowserTabId
} from '../shared/contracts'
import type {
SshHostAgentConnectionStatus
} from '../shared/ssh-host-contracts'
@@ -391,12 +397,13 @@ describe('registerIpcHandlers computer capabilities', () => {
})
it('validates computer capability requests and restricts them to the trusted renderer', async () => {
const webContents = {
const webContents = Object.assign(new EventEmitter(), {
id: 7,
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
})
const window = {
webContents,
isDestroyed: vi.fn(() => false),
@@ -444,6 +451,36 @@ describe('registerIpcHandlers computer capabilities', () => {
const stopLoading = vi.fn(async () => true)
const setViewport = vi.fn()
const releaseConversation = vi.fn(async () => {})
const createTab = vi.fn(async (conversationId: string) => ({
conversationId,
tabId: '4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
}))
const listTabs = vi.fn(() => [])
const getVisibleTabId = vi.fn(() => undefined)
const acquireTabUsage = vi.fn(
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release: vi.fn()
})
)
const closeTab = vi.fn(async () => {})
const snapshotBrowser = vi.fn(async () => ({
url: 'https://example.com/',
title: 'Example',
nodes: [],
truncated: false
}))
const clickBrowser = vi.fn(async () => {})
const typeBrowser = vi.fn(async () => {})
const selectBrowser = vi.fn(async () => {})
const screenshotBrowser = vi.fn(async () => ({
type: 'image' as const,
mimeType: 'image/jpeg' as const,
data: '/9j/2Q=='
}))
const selectFiles = vi.fn(
async (
_window: unknown,
@@ -505,9 +542,19 @@ describe('registerIpcHandlers computer capabilities', () => {
onRuntimeSettingsChanged,
undefined,
{
createTab,
listTabs,
getVisibleTabId,
acquireTabUsage,
closeTab,
navigate,
back,
reload,
snapshot: snapshotBrowser,
click: clickBrowser,
type: typeBrowser,
select: selectBrowser,
screenshot: screenshotBrowser,
stopLoading,
setViewport,
releaseConversation,
@@ -649,8 +696,16 @@ describe('registerIpcHandlers computer capabilities', () => {
)?.(event, 'unsupported')
).rejects.toThrow()
const browserTabId = browserTabIdSchema.parse(
'4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
)
const siblingBrowserTabId = browserTabIdSchema.parse(
'0387bd61-3a12-40ce-98d7-ef5d14cc8251'
)
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'ready',
sessionActive: true,
isLoading: false,
@@ -665,8 +720,22 @@ describe('registerIpcHandlers computer capabilities', () => {
status: 'ready'
})
)
const ownedStateSendCount = webContents.send.mock.calls.length
browserStateListener?.({
conversationId: 'foreign-conversation',
tabId: browserTabId,
ownerWindowId: 8,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
updatedAt: 1
})
expect(webContents.send).toHaveBeenCalledTimes(ownedStateSendCount)
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'acting',
sessionActive: true,
isLoading: false,
@@ -678,6 +747,22 @@ describe('registerIpcHandlers computer capabilities', () => {
expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl')
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: siblingBrowserTabId,
ownerWindowId: 7,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
frameDataUrl: 'data:image/jpeg;base64,frame',
updatedAt: 2
})
expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty(
'frameDataUrl'
)
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'stopped',
sessionActive: false,
isLoading: false,
@@ -686,6 +771,8 @@ describe('registerIpcHandlers computer capabilities', () => {
})
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'ready',
sessionActive: true,
isLoading: false,
@@ -703,7 +790,37 @@ describe('registerIpcHandlers computer capabilities', () => {
})
).resolves.toBeUndefined()
expect(releaseConversation).toHaveBeenCalledWith(
'browser-conversation'
'browser-conversation',
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserCreateTab)?.(event, {
conversationId: 'browser-conversation',
workbarInstanceId: siblingBrowserTabId
})
).resolves.toMatchObject({ tabId: browserTabId })
expect(createTab).toHaveBeenCalledWith(
'browser-conversation',
7,
expect.any(AbortSignal),
siblingBrowserTabId
)
await expect(
electronMocks.handlers.get(ipcChannels.browserListTabs)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toEqual([])
expect(listTabs).toHaveBeenCalledWith('browser-conversation', 7)
await expect(
electronMocks.handlers.get(ipcChannels.browserCloseTab)?.(event, {
conversationId: 'browser-conversation',
tabId: browserTabId
})
).resolves.toBeUndefined()
expect(closeTab).toHaveBeenCalledWith(
'browser-conversation',
browserTabId,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
@@ -714,7 +831,9 @@ describe('registerIpcHandlers computer capabilities', () => {
expect(navigate).toHaveBeenCalledWith(
'browser-conversation',
'https://example.com/',
expect.any(AbortSignal)
expect.any(AbortSignal),
undefined,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserBack)?.(event, {
@@ -723,7 +842,9 @@ describe('registerIpcHandlers computer capabilities', () => {
).resolves.toBeUndefined()
expect(back).toHaveBeenCalledWith(
'browser-conversation',
expect.any(AbortSignal)
expect.any(AbortSignal),
undefined,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserReload)?.(event, {
@@ -732,7 +853,9 @@ describe('registerIpcHandlers computer capabilities', () => {
).resolves.toBeUndefined()
expect(reload).toHaveBeenCalledWith(
'browser-conversation',
expect.any(AbortSignal)
expect.any(AbortSignal),
undefined,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
@@ -740,23 +863,55 @@ describe('registerIpcHandlers computer capabilities', () => {
{ conversationId: 'browser-conversation' }
)
).resolves.toBeUndefined()
expect(stopLoading).toHaveBeenCalledWith('browser-conversation')
expect(stopLoading).toHaveBeenCalledWith(
'browser-conversation',
undefined,
7
)
expect(
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
conversationId: 'browser-conversation',
leaseToken: siblingBrowserTabId,
bounds: { x: 900, y: 120, width: 320, height: 600 }
})
).toBeUndefined()
expect(setViewport).toHaveBeenCalledWith('browser-conversation', {
x: 900,
y: 120,
width: 320,
height: 600
})
expect(setViewport).toHaveBeenCalledWith(
'browser-conversation',
{ x: 900, y: 120, width: 320, height: 600 },
undefined,
siblingBrowserTabId,
7
)
expect(
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {})
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
leaseToken: siblingBrowserTabId
})
).toBeUndefined()
expect(setViewport).toHaveBeenLastCalledWith()
expect(setViewport).toHaveBeenLastCalledWith(
undefined,
undefined,
undefined,
siblingBrowserTabId,
7
)
navigate.mockImplementationOnce(
async (...args: unknown[]) =>
new Promise<never>((_resolve, reject) => {
const signal = args[2] as AbortSignal
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
)
const abandonedNavigation = electronMocks.handlers.get(
ipcChannels.browserNavigate
)?.(event, {
conversationId: 'browser-conversation',
url: 'https://example.com/abandoned'
}) as Promise<void>
await vi.waitFor(() => expect(webContents.listenerCount('destroyed')).toBe(1))
webContents.emit('destroyed')
await expect(abandonedNavigation).rejects.toThrow('所属窗口已关闭')
navigate.mockRejectedValueOnce(new BrowserNavigationStoppedError())
await expect(
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
@@ -4878,7 +5033,8 @@ describe('registerIpcHandlers agent terminal state', () => {
knowledgeGateway?: Record<string, unknown>,
magicNotesEnabled = false,
goodbuddyConfigService?: Record<string, unknown>,
capabilityServiceOverride?: Record<string, unknown>
capabilityServiceOverride?: Record<string, unknown>,
browserControl?: Record<string, unknown>
) {
const assistantDatabase = {
createTask: vi.fn(),
@@ -4990,6 +5146,7 @@ describe('registerIpcHandlers agent terminal state', () => {
}))
}
const webContents = {
id: 9,
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
@@ -5068,7 +5225,7 @@ describe('registerIpcHandlers agent terminal state', () => {
{} as never,
onRuntimeSettingsChanged,
onBeforeClearLocalData,
undefined,
browserControl as never,
subagentService as never,
undefined,
{
@@ -7230,6 +7387,7 @@ describe('registerIpcHandlers agent terminal state', () => {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
browserTabId?: BrowserTabId
}> = []
const runtime = {
runtimeId: 'opencode',
@@ -7240,6 +7398,7 @@ describe('registerIpcHandlers agent terminal state', () => {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
browserTabId?: BrowserTabId
}) {
receivedRequests.push(request)
yield { requestId: request.requestId, type: 'done' }
@@ -7257,6 +7416,30 @@ describe('registerIpcHandlers agent terminal state', () => {
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [
'builtin-browser'
])
const primaryTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000401'
)
const siblingTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000402'
)
const browserControl = {
listTabs: vi.fn(() => [
{ tabId: siblingTabId, primary: false },
{ tabId: primaryTabId, primary: true }
]),
getVisibleTabId: vi.fn(() => undefined),
acquireTabUsage: vi.fn(
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release: vi.fn()
})
),
createTab: vi.fn(),
onState: vi.fn(() => () => undefined)
}
const harness = createHarness(
runtime,
undefined,
@@ -7268,7 +7451,8 @@ describe('registerIpcHandlers agent terminal state', () => {
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds }
{ getEnabledBuiltinMcpServerIds },
browserControl
)
const event = trustedEvent(harness.webContents)
const askRequestId = '00000000-0000-4000-8000-000000000026'
@@ -7308,16 +7492,229 @@ describe('registerIpcHandlers agent terminal state', () => {
expect.any(AbortSignal),
'none',
undefined,
'browser-execute'
'browser-execute',
primaryTabId,
expect.objectContaining({
conversationId: 'browser-execute',
tabId: primaryTabId,
owner: executeRequestId
})
)
expect(receivedRequests[0]?.knowledgeCapabilityToken).toBeUndefined()
expect(receivedRequests[1]).toMatchObject({
knowledgeCapabilityToken: 'browser-capability',
browserTabId: primaryTabId,
trustedInstructions: expect.stringContaining('browser_navigate')
})
expect(browserControl.listTabs).toHaveBeenCalledWith(
'browser-execute',
harness.webContents.id
)
expect(browserControl.createTab).not.toHaveBeenCalled()
expect(browserControl.acquireTabUsage).toHaveBeenCalledWith(
'browser-execute',
primaryTabId,
executeRequestId,
harness.webContents.id
)
await harness.dispose()
})
it('prefers the owner window visible browser tab over the primary tab', async () => {
const requestId = '00000000-0000-4000-8000-000000000028'
const primaryTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000411'
)
const visibleTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000412'
)
const received: Array<{ browserTabId?: BrowserTabId }> = []
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string; browserTabId?: BrowserTabId }) {
received.push(request)
yield { requestId: request.requestId, type: 'done' }
}
}
const release = vi.fn()
const knowledgeGateway = {
grant: vi.fn(() => 'browser-capability'),
getAvailableToolNames: vi.fn(() => ['browser_navigate']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const browserControl = {
listTabs: vi.fn(() => [
{ tabId: primaryTabId, primary: true },
{ tabId: visibleTabId, primary: false }
]),
getVisibleTabId: vi.fn(() => visibleTabId),
acquireTabUsage: vi.fn(
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release
})
),
createTab: vi.fn(),
onState: vi.fn(() => () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) },
browserControl
)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'visible-browser',
prompt: '使用当前网页',
workMode: 'execute',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(received[0]?.browserTabId).toBe(visibleTabId)
expect(browserControl.acquireTabUsage).toHaveBeenCalledWith(
'visible-browser',
visibleTabId,
requestId,
harness.webContents.id
)
expect(browserControl.createTab).not.toHaveBeenCalled()
await harness.dispose()
})
it.each(['error', 'cancel'] as const)(
'releases a request-created browser tab lease on runtime %s',
async (outcome) => {
const requestId = outcome === 'error'
? '00000000-0000-4000-8000-000000000029'
: '00000000-0000-4000-8000-000000000030'
const tabId = browserTabIdSchema.parse(
outcome === 'error'
? '00000000-0000-4000-8000-000000000421'
: '00000000-0000-4000-8000-000000000422'
)
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }, signal: AbortSignal) {
markStarted()
if (outcome === 'error') throw new Error('runtime failed')
await new Promise<void>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
yield { requestId: request.requestId, type: 'done' }
}
}
const release = vi.fn()
let grantedLease:
| { release(): void }
| undefined
const knowledgeGateway = {
grant: vi.fn((...args: unknown[]) => {
grantedLease = args[7] as { release(): void }
return 'browser-capability'
}),
getAvailableToolNames: vi.fn(() => ['browser_navigate']),
drainReferences: vi.fn(() => []),
revoke: vi.fn(() => grantedLease?.release())
}
const createTab = vi.fn<
(
conversationId: string,
ownerWindowId?: number,
signal?: AbortSignal,
workbarInstanceId?: string
) => Promise<{ tabId: BrowserTabId }>
>(async () => ({ tabId }))
const browserControl = {
listTabs: vi.fn(() => []),
getVisibleTabId: vi.fn(() => undefined),
createTab,
acquireTabUsage: vi.fn(
(conversationId: string, acquiredTabId: BrowserTabId, owner: string) => ({
conversationId,
tabId: acquiredTabId,
owner,
signal: new AbortController().signal,
release
})
),
onState: vi.fn(() => () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) },
browserControl
)
const event = trustedEvent(harness.webContents)
await harness.handler?.(event, {
requestId,
conversationId: `browser-${outcome}`,
prompt: '使用浏览器',
workMode: 'execute',
knowledgeLibraryIds: []
})
await started
if (outcome === 'cancel') {
harness.cancelHandler?.(event, requestId)
}
await vi.waitFor(() => expect(release).toHaveBeenCalledOnce())
const workbarInstanceId = createTab.mock.calls[0]?.[3]
expect(workbarInstanceId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
)
expect(browserControl.acquireTabUsage).toHaveBeenCalledWith(
`browser-${outcome}`,
tabId,
requestId,
harness.webContents.id
)
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
'browser-capability'
)
await harness.dispose()
}
)
it.each(['ask', 'execute'] as const)(
'does not grant or advertise scoped data tools to external OpenCode in %s mode',
async (workMode) => {
@@ -9400,7 +9797,7 @@ describe('registerIpcHandlers agent terminal state', () => {
) => Promise<string>
) {
authorization = await authorize({
scopeKey: 'model:builtin:workspace_write_text',
scopeKey: 'model:builtin:workspace_apply_patch',
title: '写入文件',
description: '写入 README.md'
})
+401 -32
View File
@@ -4,7 +4,8 @@ import {
clipboard,
dialog,
ipcMain,
shell
shell,
type IpcMainInvokeEvent
} from 'electron'
import {
lstat,
@@ -13,7 +14,7 @@ import {
realpath,
stat
} from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { createHash, randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { basename, extname, isAbsolute, join } from 'node:path'
import { z } from 'zod'
@@ -27,11 +28,19 @@ import {
agentQuestionResponseSchema,
agentRequestSchema,
browserBackRequestSchema,
browserClickRequestSchema,
browserCloseTabRequestSchema,
browserCreateTabRequestSchema,
browserListTabsRequestSchema,
browserNavigateRequestSchema,
browserReloadRequestSchema,
browserScreenshotRequestSchema,
browserSelectRequestSchema,
browserSetViewportRequestSchema,
browserSnapshotRequestSchema,
browserStopLoadingRequestSchema,
browserStopRequestSchema,
browserTypeRequestSchema,
clipboardTextSchema,
conversationQueueUserInputSchema,
defaultRuntimeSettings,
@@ -62,6 +71,7 @@ import {
type AgentRequest,
type AppInfo,
type BrowserLiveState,
type BrowserTabId,
type ConversationQueueDispatch,
type ConversationQueueUserInput,
type KnowledgeSearchReference,
@@ -442,10 +452,102 @@ function runtimeTargetFor(
type ScopedDataCapability = {
token?: string
toolNames: readonly string[]
browserTabId?: BrowserTabId
}
function grantScopedDataCapability(input: {
type BrowserRequestTabUsageLease = {
readonly conversationId: string
readonly tabId: BrowserTabId
readonly owner: string
readonly signal: AbortSignal
release(): void
}
type BrowserCapabilityControl = {
createTab(
conversationId: string,
ownerWindowId?: number,
signal?: AbortSignal,
workbarInstanceId?: string
): Promise<{ tabId: BrowserTabId }>
listTabs(
conversationId: string,
ownerWindowId?: number
): Array<{ tabId: BrowserTabId; primary: boolean }>
getVisibleTabId(
conversationId: string,
ownerWindowId?: number
): BrowserTabId | undefined
acquireTabUsage(
conversationId: string,
tabId: BrowserTabId,
owner: string,
ownerWindowId?: number
): BrowserRequestTabUsageLease
}
function requestWorkbarInstanceId(owner: string): string {
const bytes = createHash('sha256')
.update('goodbuddy:browser-request-workbar:')
.update(owner)
.digest()
.subarray(0, 16)
bytes[6] = (bytes[6]! & 0x0f) | 0x50
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = bytes.toString('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
async function bindBrowserTab(input: {
control?: BrowserCapabilityControl
conversationId: string
browserTabId?: BrowserTabId
owner: string
ownerWindowId?: number
signal: AbortSignal
}): Promise<BrowserRequestTabUsageLease> {
if (!input.control) {
throw new Error('GoodBuddy 内置浏览器服务不可用')
}
const tabs = input.control.listTabs(
input.conversationId,
input.ownerWindowId
)
let tabId: BrowserTabId
if (input.browserTabId) {
if (!tabs.some((tab) => tab.tabId === input.browserTabId)) {
throw new Error('浏览器标签页不存在或不属于当前对话')
}
tabId = input.browserTabId
} else {
const visibleTabId = input.control.getVisibleTabId(
input.conversationId,
input.ownerWindowId
)
const primary = tabs.find((tab) => tab.primary)
tabId = visibleTabId && tabs.some((tab) => tab.tabId === visibleTabId)
? visibleTabId
: primary?.tabId ??
(
await input.control.createTab(
input.conversationId,
input.ownerWindowId,
input.signal,
requestWorkbarInstanceId(input.owner)
)
).tabId
}
return input.control.acquireTabUsage(
input.conversationId,
tabId,
input.owner,
input.ownerWindowId
)
}
async function grantScopedDataCapability(input: {
gateway?: KnowledgeMcpGateway
browserControl?: BrowserCapabilityControl
runtime: AgentRuntime
enabledServers: readonly BuiltinMcpServerId[]
requestId: string
@@ -454,12 +556,15 @@ function grantScopedDataCapability(input: {
configAccess?: MagicNotesCapabilityAccess
workspacePath?: string
browserConversationId?: string
browserTabId?: BrowserTabId
ownerWindowId?: number
authorizeConfigApply?: (
event: GoodBuddyConfigApplyEvent,
signal: AbortSignal
) => Promise<boolean>
signal: AbortSignal
}): ScopedDataCapability {
abort?: (reason: unknown) => void
}): Promise<ScopedDataCapability> {
const enabledServers = new Set(input.enabledServers)
const libraryIds = enabledServers.has('knowledge-base')
? input.libraryIds
@@ -485,6 +590,22 @@ function grantScopedDataCapability(input: {
if (!input.gateway) {
throw new Error('GoodBuddy 内置工具服务不可用')
}
const browserUsageLease = browserConversationId
? await bindBrowserTab({
control: input.browserControl,
conversationId: browserConversationId,
browserTabId: input.browserTabId,
owner: input.requestId,
ownerWindowId: input.ownerWindowId,
signal: input.signal
})
: undefined
const browserTabId = browserUsageLease?.tabId
browserUsageLease?.signal.addEventListener(
'abort',
() => input.abort?.(browserUsageLease.signal.reason),
{ once: true }
)
const config =
configAccess !== 'none' && input.workspacePath
? {
@@ -500,7 +621,9 @@ function grantScopedDataCapability(input: {
input.signal,
magicNotesAccess,
config,
browserConversationId
browserConversationId,
browserTabId,
browserUsageLease
)
: config
? input.gateway.grant(
@@ -518,6 +641,7 @@ function grantScopedDataCapability(input: {
)
return {
token,
browserTabId,
toolNames: token
? input.gateway.getAvailableToolNames(token)
: []
@@ -1086,25 +1210,98 @@ export function registerIpcHandlers(
onRuntimeSettingsChanged: () => Promise<void>,
onBeforeClearLocalData?: () => Promise<void>,
browserControl?: {
createTab(
conversationId: string,
ownerWindowId?: number,
signal?: AbortSignal,
workbarInstanceId?: string
): Promise<unknown>
listTabs(
conversationId: string,
ownerWindowId?: number
): unknown[]
getVisibleTabId(
conversationId: string,
ownerWindowId?: number
): BrowserTabId | undefined
acquireTabUsage(
conversationId: string,
tabId: BrowserTabId,
owner: string,
ownerWindowId?: number
): BrowserRequestTabUsageLease
closeTab(
conversationId: string,
tabId: BrowserTabId,
ownerWindowId?: number
): Promise<void>
navigate(
conversationId: string,
url: string,
signal: AbortSignal
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<{ url: string; origin: string }>
back(
conversationId: string,
signal: AbortSignal
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<{ url: string; origin: string }>
reload(
conversationId: string,
signal: AbortSignal
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<{ url: string; origin: string }>
stopLoading(conversationId: string): boolean | Promise<boolean>
snapshot(
conversationId: string,
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<unknown>
click(
conversationId: string,
ref: string,
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<void>
type(
conversationId: string,
ref: string,
text: string,
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<void>
select(
conversationId: string,
ref: string,
value: string,
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<void>
screenshot(
conversationId: string,
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<unknown>
stopLoading(
conversationId: string,
tabId?: BrowserTabId,
ownerWindowId?: number
): boolean | Promise<boolean>
setViewport(
conversationId?: string,
bounds?: { x: number; y: number; width: number; height: number }
): void
releaseConversation(conversationId: string): Promise<void>
bounds?: { x: number; y: number; width: number; height: number },
tabId?: BrowserTabId,
leaseToken?: string,
ownerWindowId?: number
): boolean | void
releaseConversation(conversationId: string, ownerWindowId?: number): Promise<void>
onState(listener: (state: BrowserLiveState) => void): () => void
},
subagentService?: SubagentService,
@@ -1378,21 +1575,22 @@ export function registerIpcHandlers(
window.on('unmaximize', notifyMaximizedChanged)
const lastSentBrowserFrames = new Map<string, string>()
const removeBrowserStateListener = browserControl?.onState((state) => {
if (!window.isDestroyed()) {
if (!window.isDestroyed() && state.ownerWindowId === window.webContents.id) {
const tabStateKey = `${state.conversationId}\u0000${state.tabId}`
const frame = state.frameDataUrl
let payload: BrowserLiveState = state
if (
frame &&
lastSentBrowserFrames.get(state.conversationId) === frame
lastSentBrowserFrames.get(tabStateKey) === frame
) {
payload = { ...state }
delete payload.frameDataUrl
}
if (frame) {
lastSentBrowserFrames.set(state.conversationId, frame)
lastSentBrowserFrames.set(tabStateKey, frame)
}
if (state.status === 'stopped') {
lastSentBrowserFrames.delete(state.conversationId)
lastSentBrowserFrames.delete(tabStateKey)
}
window.webContents.send(ipcChannels.browserState, payload)
}
@@ -2343,8 +2541,9 @@ export function registerIpcHandlers(
(id) => id !== 'builtin-browser'
)
: []
const notesCapability = grantScopedDataCapability({
const notesCapability = await grantScopedDataCapability({
gateway: knowledgeGateway,
browserControl: browserControl as BrowserCapabilityControl | undefined,
runtime: requestRuntime,
enabledServers: enabledBuiltinMcpServers,
requestId,
@@ -2358,7 +2557,9 @@ export function registerIpcHandlers(
schedule.workMode === 'execute'
? runtimeConversationId
: undefined,
signal: controller.signal
ownerWindowId: window.webContents.id,
signal: controller.signal,
abort: (reason) => controller.abort(reason)
})
knowledgeCapabilityToken = notesCapability.token
const noteTools = notesCapability.toolNames
@@ -2456,6 +2657,9 @@ export function registerIpcHandlers(
trustedInstructions,
...(knowledgeCapabilityToken
? { knowledgeCapabilityToken }
: {}),
...(notesCapability.browserTabId
? { browserTabId: notesCapability.browserTabId }
: {})
}
for await (const agentEvent of requestRuntime.run(
@@ -3467,7 +3671,7 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const request = browserStopRequestSchema.parse(input)
await Promise.allSettled([
browserControl?.releaseConversation(request.conversationId),
browserControl?.releaseConversation(request.conversationId, event.sender.id),
selectedRuntimes
? selectedRuntimes.releaseConversation(request.conversationId)
: runtime.releaseConversation?.(request.conversationId)
@@ -3490,7 +3694,45 @@ export function registerIpcHandlers(
return browserControl
}
const runBrowserOperation = async <T>(
event: IpcMainInvokeEvent,
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs = 30_000
): Promise<T> => {
const controller = new AbortController()
const sender = event.sender
const senderDestroyed = (): void => {
controller.abort(new Error('浏览器请求所属窗口已关闭'))
}
if (sender.isDestroyed()) senderDestroyed()
else sender.once?.('destroyed', senderDestroyed)
try {
const signal = AbortSignal.any([
controller.signal,
AbortSignal.timeout(timeoutMs)
])
signal.throwIfAborted()
return await new Promise<T>((resolve, reject) => {
const aborted = (): void => reject(signal.reason)
signal.addEventListener('abort', aborted, { once: true })
void operation(signal).then(
(value) => {
signal.removeEventListener('abort', aborted)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', aborted)
reject(error)
}
)
})
} finally {
sender.removeListener?.('destroyed', senderDestroyed)
}
}
const runUiBrowserNavigation = async (
event: IpcMainInvokeEvent,
operation: (
control: NonNullable<typeof browserControl>,
signal: AbortSignal
@@ -3498,7 +3740,7 @@ export function registerIpcHandlers(
): Promise<void> => {
try {
const control = await requireBrowserControl()
await operation(control, new AbortController().signal)
await runBrowserOperation(event, (signal) => operation(control, signal))
} catch (error) {
if (!(error instanceof BrowserNavigationStoppedError)) {
throw error
@@ -3506,16 +3748,56 @@ export function registerIpcHandlers(
}
}
registerHandler(
ipcChannels.browserCreateTab,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserCreateTabRequestSchema.parse(input)
const control = await requireBrowserControl()
return runBrowserOperation(event, (signal) =>
control.createTab(
request.conversationId,
event.sender.id,
signal,
request.workbarInstanceId
)
)
}
)
registerHandler(ipcChannels.browserListTabs, async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserListTabsRequestSchema.parse(input)
const control = await requireBrowserControl()
return control.listTabs(request.conversationId, event.sender.id)
})
registerHandler(
ipcChannels.browserCloseTab,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserCloseTabRequestSchema.parse(input)
const control = await requireBrowserControl()
await control.closeTab(
request.conversationId,
request.tabId,
event.sender.id
)
}
)
registerHandler(
ipcChannels.browserNavigate,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserNavigateRequestSchema.parse(input)
await runUiBrowserNavigation((control, signal) =>
await runUiBrowserNavigation(event, (control, signal) =>
control.navigate(
request.conversationId,
request.url,
signal
signal,
request.tabId,
event.sender.id
)
)
}
@@ -3526,10 +3808,12 @@ export function registerIpcHandlers(
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserBackRequestSchema.parse(input)
await runUiBrowserNavigation((control, signal) =>
await runUiBrowserNavigation(event, (control, signal) =>
control.back(
request.conversationId,
signal
signal,
request.tabId,
event.sender.id
)
)
}
@@ -3540,10 +3824,12 @@ export function registerIpcHandlers(
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserReloadRequestSchema.parse(input)
await runUiBrowserNavigation((control, signal) =>
await runUiBrowserNavigation(event, (control, signal) =>
control.reload(
request.conversationId,
signal
signal,
request.tabId,
event.sender.id
)
)
}
@@ -3554,7 +3840,79 @@ export function registerIpcHandlers(
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserStopLoadingRequestSchema.parse(input)
await browserControl?.stopLoading(request.conversationId)
await browserControl?.stopLoading(
request.conversationId,
request.tabId,
event.sender.id
)
}
)
registerHandler(ipcChannels.browserSnapshot, async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserSnapshotRequestSchema.parse(input)
const control = await requireBrowserControl()
return runBrowserOperation(event, (signal) => control.snapshot(
request.conversationId,
signal,
request.tabId,
event.sender.id
))
})
registerHandler(ipcChannels.browserClick, async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserClickRequestSchema.parse(input)
const control = await requireBrowserControl()
await runBrowserOperation(event, (signal) => control.click(
request.conversationId,
request.ref,
signal,
request.tabId,
event.sender.id
))
})
registerHandler(ipcChannels.browserType, async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserTypeRequestSchema.parse(input)
const control = await requireBrowserControl()
await runBrowserOperation(event, (signal) => control.type(
request.conversationId,
request.ref,
request.text,
signal,
request.tabId,
event.sender.id
))
})
registerHandler(ipcChannels.browserSelect, async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserSelectRequestSchema.parse(input)
const control = await requireBrowserControl()
await runBrowserOperation(event, (signal) => control.select(
request.conversationId,
request.ref,
request.value,
signal,
request.tabId,
event.sender.id
))
})
registerHandler(
ipcChannels.browserScreenshot,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const request = browserScreenshotRequestSchema.parse(input)
const control = await requireBrowserControl()
return runBrowserOperation(event, (signal) => control.screenshot(
request.conversationId,
signal,
request.tabId,
event.sender.id
))
}
)
@@ -3789,8 +4147,9 @@ export function registerIpcHandlers(
: undefined
: resolvedRuntimeSettings?.workspacePath
const controller = new AbortController()
const scopedCapability = grantScopedDataCapability({
const scopedCapability = await grantScopedDataCapability({
gateway: knowledgeGateway,
browserControl: browserControl as BrowserCapabilityControl | undefined,
runtime: selectedRuntime,
enabledServers: enabledBuiltinMcpServers,
requestId: enrichedRequest.requestId,
@@ -3807,7 +4166,9 @@ export function registerIpcHandlers(
enrichedRequest.workMode === 'execute'
? enrichedRequest.conversationId
: undefined,
signal: controller.signal
ownerWindowId: event.sender.id,
signal: controller.signal,
abort: (reason) => controller.abort(reason)
})
const knowledgeCapabilityToken = scopedCapability.token
const availableTools = [
@@ -3846,6 +4207,9 @@ export function registerIpcHandlers(
const request: AgentExecutionRequest = knowledgeCapabilityToken
? { ...baseRequest, knowledgeCapabilityToken }
: baseRequest
if (scopedCapability.browserTabId) {
request.browserTabId = scopedCapability.browserTabId
}
const managedSshExecution =
agentRuntimeSelected &&
configExecutionSpace?.kind === 'ssh'
@@ -4649,7 +5013,6 @@ export function registerIpcHandlers(
(message) =>
message.state === 'complete' && message.content.trim()
)
.slice(-500)
if (
persistedHistory.length !== request.history.length ||
persistedHistory.some(
@@ -6639,7 +7002,13 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const request = browserSetViewportRequestSchema.parse(input)
if (!request.conversationId || !request.bounds) {
browserControl?.setViewport()
browserControl?.setViewport(
undefined,
undefined,
undefined,
request.leaseToken,
event.sender.id
)
return
}
browserControl?.setViewport(request.conversationId, {
@@ -6647,7 +7016,7 @@ export function registerIpcHandlers(
y: request.bounds.y,
width: request.bounds.width,
height: request.bounds.height
})
}, request.tabId, request.leaseToken, event.sender.id)
}
)
registerHandler(
+15 -11
View File
@@ -515,12 +515,10 @@ const defaultSettings: StoredSettings = {
],
defaultModelProfileId,
opencodeModelSource: {
kind: 'profile',
profileId: defaultModelProfileId
kind: 'default'
},
continueModelSource: {
kind: 'profile',
profileId: defaultModelProfileId
kind: 'default'
},
opencodeBaseUrl: defaultRuntimeSettings.opencodeBaseUrl,
opencodeEmbedded: defaultRuntimeSettings.opencodeEmbedded,
@@ -820,7 +818,7 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
const normalizeSource = (
source: RuntimeSettings['opencodeModelSource']
): RuntimeSettings['opencodeModelSource'] => {
if (source.kind === 'platform') {
if (source.kind !== 'profile') {
return source
}
const profile = modelProfiles.find(
@@ -839,6 +837,7 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings {
if (!source || source.kind === 'platform') {
return { kind: 'platform' }
}
if (source.kind === 'default') return source
const profile = modelProfiles.find(
(candidate) => candidate.id === source.profileId
)
@@ -1431,6 +1430,9 @@ export class RuntimeSettingsStore {
modelProfiles: readonly ResolvedModelProfile[]
): ResolvedModelProfile | undefined {
const source = settings.deepseekHarnessModelSource
if (source.kind === 'default') {
return modelProfiles.find((profile) => profile.id === settings.defaultModelProfileId)
}
if (source.kind === 'profile') {
return modelProfiles.find(
(profile) => profile.id === source.profileId
@@ -1936,12 +1938,14 @@ export class RuntimeSettingsStore {
)
const opencodeModelProfile =
!agent.opencodeBaseUrl &&
settings.opencodeModelSource.kind === 'profile'
? profilesById.get(settings.opencodeModelSource.profileId)
settings.opencodeModelSource.kind !== 'platform'
? profilesById.get(settings.opencodeModelSource.kind === 'default'
? settings.defaultModelProfileId : settings.opencodeModelSource.profileId)
: undefined
const continueModelProfile =
settings.continueModelSource.kind === 'profile'
? profilesById.get(settings.continueModelSource.profileId)
settings.continueModelSource.kind !== 'platform'
? profilesById.get(settings.continueModelSource.kind === 'default'
? settings.defaultModelProfileId : settings.continueModelSource.profileId)
: undefined
const deepseekHarnessModelProfile =
this.resolveDeepSeekHarnessModelProfile(settings, modelProfiles)
@@ -2362,7 +2366,7 @@ export class RuntimeSettingsStore {
source: RuntimeSettings['opencodeModelSource'],
runtimeLabel: 'OpenCode' | 'Continue'
): void => {
if (source.kind === 'platform') {
if (source.kind !== 'profile') {
return
}
const profile = modelProfiles.find(
@@ -2386,7 +2390,7 @@ export class RuntimeSettingsStore {
const repairRuntimeSource = (
source: RuntimeSettings['opencodeModelSource']
): RuntimeSettings['opencodeModelSource'] => {
if (source.kind === 'platform') {
if (source.kind !== 'profile') {
return source
}
const profile = modelProfiles.find(
+84 -22
View File
@@ -7,7 +7,23 @@ import {
type AgentRuntimeDetection,
type AgentRuntimeStatus,
type AppInfo,
type BrowserBackRequest,
type BrowserClickRequest,
type BrowserCloseTabRequest,
type BrowserCreateTabRequest,
type BrowserLiveState,
type BrowserListTabsRequest,
type BrowserNavigateRequest,
type BrowserReloadRequest,
type BrowserScreenshotRequest,
type BrowserScreenshotResult,
type BrowserSelectRequest,
type BrowserSetViewportRequest,
type BrowserSnapshotRequest,
type BrowserSnapshotResult,
type BrowserStopLoadingRequest,
type BrowserTabSummary,
type BrowserTypeRequest,
type ContextAttachment,
type ContextFileSelectionProgress,
type DesktopApi,
@@ -187,6 +203,7 @@ import type {
TerminalWriteRequest
} from '../shared/terminal-contracts'
const desktopApi: DesktopApi = {
app: {
getInfo: () => ipcRenderer.invoke(ipcChannels.appInfo) as Promise<AppInfo>,
@@ -307,32 +324,77 @@ const desktopApi: DesktopApi = {
}
},
browser: {
navigate: async (conversationId: string, url: string) => {
await ipcRenderer.invoke(ipcChannels.browserNavigate, {
conversationId,
url
})
createTab: (request: BrowserCreateTabRequest) =>
ipcRenderer.invoke(
ipcChannels.browserCreateTab,
request
) as Promise<BrowserTabSummary>,
listTabs: (request: BrowserListTabsRequest) =>
ipcRenderer.invoke(
ipcChannels.browserListTabs,
request
) as Promise<BrowserTabSummary[]>,
closeTab: async (request: BrowserCloseTabRequest) => {
await ipcRenderer.invoke(ipcChannels.browserCloseTab, request)
},
back: async (conversationId: string) => {
await ipcRenderer.invoke(ipcChannels.browserBack, {
conversationId
})
navigate: async (
requestOrConversation: BrowserNavigateRequest | string,
url?: string
) => {
await ipcRenderer.invoke(
ipcChannels.browserNavigate,
typeof requestOrConversation === 'string'
? { conversationId: requestOrConversation, url }
: requestOrConversation
)
},
reload: async (conversationId: string) => {
await ipcRenderer.invoke(ipcChannels.browserReload, {
conversationId
})
back: async (requestOrConversation: BrowserBackRequest | string) => {
await ipcRenderer.invoke(
ipcChannels.browserBack,
typeof requestOrConversation === 'string'
? { conversationId: requestOrConversation }
: requestOrConversation
)
},
stopLoading: async (conversationId: string) => {
await ipcRenderer.invoke(ipcChannels.browserStopLoading, {
conversationId
})
reload: async (requestOrConversation: BrowserReloadRequest | string) => {
await ipcRenderer.invoke(
ipcChannels.browserReload,
typeof requestOrConversation === 'string'
? { conversationId: requestOrConversation }
: requestOrConversation
)
},
setViewport: async (conversationId, bounds) => {
await ipcRenderer.invoke(ipcChannels.browserSetViewport, {
...(conversationId ? { conversationId } : {}),
...(bounds ? { bounds } : {})
})
stopLoading: async (
requestOrConversation: BrowserStopLoadingRequest | string
) => {
await ipcRenderer.invoke(
ipcChannels.browserStopLoading,
typeof requestOrConversation === 'string'
? { conversationId: requestOrConversation }
: requestOrConversation
)
},
snapshot: (request: BrowserSnapshotRequest) =>
ipcRenderer.invoke(
ipcChannels.browserSnapshot,
request
) as Promise<BrowserSnapshotResult>,
click: async (request: BrowserClickRequest) => {
await ipcRenderer.invoke(ipcChannels.browserClick, request)
},
type: async (request: BrowserTypeRequest) => {
await ipcRenderer.invoke(ipcChannels.browserType, request)
},
select: async (request: BrowserSelectRequest) => {
await ipcRenderer.invoke(ipcChannels.browserSelect, request)
},
screenshot: (request: BrowserScreenshotRequest) =>
ipcRenderer.invoke(
ipcChannels.browserScreenshot,
request
) as Promise<BrowserScreenshotResult>,
setViewport: async (request: BrowserSetViewportRequest) => {
await ipcRenderer.invoke(ipcChannels.browserSetViewport, request)
},
stop: async (conversationId: string) => {
await ipcRenderer.invoke(
+25 -1
View File
@@ -47,16 +47,40 @@ describe('sandboxed preload', () => {
/browser: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}terminal:/u
)?.groups?.body ?? ''
for (const method of [
'createTab:',
'listTabs:',
'closeTab:',
'navigate:',
'back:',
'reload:',
'stopLoading:',
'interact:',
'snapshot:',
'click:',
'type:',
'select:',
'screenshot:',
'setViewport:',
'stop:',
'onState:'
]) {
expect(browser).toContain(method)
}
for (const channel of [
'browserCreateTab',
'browserListTabs',
'browserCloseTab',
'browserSnapshot',
'browserClick',
'browserType',
'browserSelect',
'browserScreenshot',
'browserSetViewport'
]) {
expect(browser).toContain(`ipcChannels.${channel}`)
}
expect(browser).toContain('requestOrConversation: BrowserNavigateRequest')
expect(browser).toContain('setViewport: async (request: BrowserSetViewportRequest)')
expect(browser).not.toContain('leaseId')
expect(browser).not.toMatch(
/\b(?:webContents|debugger|session|partition|cookie)\b/iu
)
@@ -7,7 +7,18 @@ import {
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceFilePreview } from '../../shared/assistant-contracts'
import type {
AssistantProject,
AssistantTask,
WorkspaceFilePreview
} from '../../shared/assistant-contracts'
import type {
BrowserCreateTabRequest,
BrowserLiveState,
BrowserSetViewportRequest,
BrowserTabId,
BrowserTabSummary
} from '../../shared/contracts'
import {
RightAssistantSidebar,
type AssistantSidebarTab,
@@ -17,15 +28,74 @@ import {
afterEach(cleanup)
const firstBrowserTabId =
'00000000-0000-4000-8000-000000000401' as BrowserTabId
const secondBrowserTabId =
'00000000-0000-4000-8000-000000000402' as BrowserTabId
const browserApi = {
createTab: vi.fn<(request: BrowserCreateTabRequest) => Promise<BrowserTabSummary>>(),
closeTab: vi.fn(async () => undefined),
setViewport: vi.fn<
(request: BrowserSetViewportRequest) => Promise<void>
>(async () => undefined)
}
function browserSummary(
conversationId: string,
tabId: BrowserTabId,
primary: boolean
): BrowserTabSummary {
return {
conversationId,
tabId,
primary,
status: 'ready',
isLoading: false,
canGoBack: false,
createdAt: 1,
updatedAt: 1
}
}
beforeEach(() => {
localStorage.clear()
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1400
})
browserApi.createTab.mockReset()
browserApi.closeTab.mockReset().mockResolvedValue(undefined)
browserApi.setViewport.mockReset().mockResolvedValue(undefined)
const tabsByOwner = new Map<string, BrowserTabId>()
browserApi.createTab.mockImplementation(async ({ conversationId, workbarInstanceId }) => {
const ownershipKey = `${conversationId}:${workbarInstanceId}`
let tabId = tabsByOwner.get(ownershipKey)
if (!tabId) {
tabId = tabsByOwner.size === 0 ? firstBrowserTabId : secondBrowserTabId
tabsByOwner.set(ownershipKey, tabId)
}
return browserSummary(conversationId, tabId, tabsByOwner.size === 1)
})
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: { browser: browserApi }
})
})
function renderSidebar({
const currentProject: AssistantProject = {
id: '00000000-0000-4000-8000-000000000301',
name: '当前项目',
description: '',
rootPath: 'D:\\project',
defaultWorkMode: 'ask',
kind: 'user',
executionSpace: { kind: 'local', rootPath: 'D:\\project' },
status: 'active',
createdAt: '2026-09-09T00:00:00.000Z',
updatedAt: '2026-09-09T00:00:00.000Z'
}
function sidebarElement({
tab = 'tasks',
approvals = [],
artifacts = [],
@@ -36,8 +106,16 @@ function renderSidebar({
})),
onLoadArtifact = vi.fn(async () => undefined),
onLoadWorkspaceFile = vi.fn(),
tasks = [],
activeProject = currentProject,
workspaceProjectId,
restoreFocusRef
restoreFocusRef,
activeConversationId,
browserStates,
onBackBrowser,
onNavigateBrowser,
onReloadBrowser,
onStopLoadingBrowser
}: {
tab?: AssistantSidebarTab
approvals?: PendingSidebarApproval[]
@@ -58,31 +136,46 @@ function renderSidebar({
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
tasks?: AssistantTask[]
activeProject?: AssistantProject | null
workspaceProjectId?: string
restoreFocusRef?: { current: HTMLElement | null }
} = {}): HTMLElement {
render(
activeConversationId?: string
browserStates?: Readonly<Record<string, Readonly<Record<string, BrowserLiveState>>>>
onBackBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
} = {}): React.JSX.Element {
return (
<div>
<main className="workspace" />
<RightAssistantSidebar
approvals={approvals}
activeConversationId={activeConversationId}
artifacts={artifacts}
browserStates={browserStates}
schedules={[]}
tasks={[]}
tasks={tasks}
conversationTitles={new Map()}
currentProject={activeProject ?? undefined}
projectNames={new Map()}
onCreateCustomTask={vi.fn()}
onBackBrowser={onBackBrowser}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={onListWorkspaceDirectory}
onLoadArtifact={onLoadArtifact}
onLoadWorkspaceFile={onLoadWorkspaceFile}
onNavigateBrowser={onNavigateBrowser}
onLoadWorkspaceDiff={vi.fn()}
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
onRefreshChanges={vi.fn(async () => undefined)}
onReloadBrowser={onReloadBrowser}
onRemoveSchedule={vi.fn(async () => undefined)}
onRespondApproval={vi.fn()}
onRunSchedule={vi.fn(async () => undefined)}
onSetScheduleEnabled={vi.fn(async () => undefined)}
onStopLoadingBrowser={onStopLoadingBrowser}
onOpenTask={vi.fn()}
onTabChange={vi.fn()}
open
@@ -92,7 +185,10 @@ function renderSidebar({
/>
</div>
)
}
function renderSidebar(options: Parameters<typeof sidebarElement>[0] = {}): HTMLElement {
render(sidebarElement(options))
return screen.getByRole('complementary', {
name: '助手工作栏'
})
@@ -257,7 +353,7 @@ describe('RightAssistantSidebar resizing', () => {
expect(
screen.getAllByRole('tab').map((tab) => tab.textContent)
).toEqual(['任务中心', '工作区', '浏览器', '成果'])
).toEqual(['任务中心', '工作区', '浏览器 · 未绑定会话', '成果'])
expect(
screen.queryByRole('tab', { name: '预览' })
).not.toBeInTheDocument()
@@ -289,6 +385,16 @@ describe('RightAssistantSidebar resizing', () => {
screen.getByRole('separator', { name: '调整助手工作栏宽度' })
).toHaveAttribute('tabindex', '-1')
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
expect(sidebar).not.toHaveClass('assistant-sidebar--browser-fullscreen')
fireEvent.click(screen.getByRole('tab', { name: /浏览器/u }))
expect(
screen.getByRole('button', { name: '全屏显示浏览器' })
).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(
screen.getByRole('button', { name: '全屏显示浏览器' })
)
fireEvent.keyDown(window, { key: 'Escape' })
expect(sidebar).not.toHaveClass(
'assistant-sidebar--browser-fullscreen'
@@ -332,6 +438,7 @@ describe('RightAssistantSidebar resizing', () => {
conversationId: 'conversation-1',
messageId: 'message-1',
approvalId: 'approval-1',
projectId: currentProject.id,
title: '写入工作区',
description: '更新 release.md',
toolName: 'write_file'
@@ -452,6 +559,102 @@ describe('RightAssistantSidebar resizing', () => {
expect(onListWorkspaceDirectory).toHaveBeenCalledTimes(2)
})
it('scopes approvals by their owning conversation project', () => {
renderSidebar({
approvals: [
{
conversationId: 'conversation-current',
projectId: currentProject.id,
messageId: 'message-current',
approvalId: 'approval-current',
title: '当前项目审批',
description: 'current'
},
{
conversationId: 'conversation-global',
messageId: 'message-global',
approvalId: 'approval-global',
title: '全局审批',
description: 'global'
}
]
})
expect(screen.getByText('当前项目审批')).toBeInTheDocument()
expect(screen.queryByText('全局审批')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '全局' }
)
)
expect(screen.getByText('全局审批')).toBeInTheDocument()
expect(screen.queryByText('当前项目审批')).not.toBeInTheDocument()
expect(screen.getByLabelText('等待审批: 1')).toBeInTheDocument()
})
it('scopes tasks to the current project, global tasks, or all projects and persists the choice', () => {
const createTask = (
id: string,
title: string,
projectId?: string
): AssistantTask => ({
id,
projectId,
title,
instructions: title,
origin: 'schedule',
status: 'running',
createdAt: '2026-09-09T00:00:00.000Z'
})
renderSidebar({
tasks: [
createTask('task-current', '当前项目任务', currentProject.id),
createTask('task-global', '全局任务'),
createTask(
'task-other',
'其他项目任务',
'00000000-0000-4000-8000-000000000302'
)
]
})
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.queryByText('全局任务')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '全局' }
)
)
expect(screen.getByText('全局任务')).toBeInTheDocument()
expect(screen.queryByText('当前项目任务')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '所有项目' }
)
)
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.getByText('全局任务')).toBeInTheDocument()
expect(screen.getByText('其他项目任务')).toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!))
.toEqual(expect.objectContaining({ taskScope: 'all-projects' }))
})
it('shows a clear current-project empty state when no project is active', () => {
renderSidebar({ activeProject: null })
expect(
screen.getByText(
'没有活动项目。请选择其他任务范围或先打开一个项目。'
)
).toBeInTheDocument()
expect(screen.queryByText('等待审批')).not.toBeInTheDocument()
})
it('keeps preview content and reading position when refreshing fails', async () => {
const onLoadWorkspaceFile = vi.fn().mockResolvedValueOnce({
path: 'README.md', name: 'README.md', content: '# Original heading',
@@ -590,4 +793,342 @@ describe('RightAssistantSidebar resizing', () => {
screen.queryByRole('tab', { name: '预览' })
).not.toBeInTheDocument()
})
it('keeps browser A selected and usable after switching to B, and binds a new browser to B', async () => {
const onNavigateBrowser = vi.fn(async () => undefined)
const onBackBrowser = vi.fn(async () => undefined)
const onReloadBrowser = vi.fn(async () => undefined)
const onStopLoadingBrowser = vi.fn(async () => undefined)
const browserStates = {
'conversation-a': {
[firstBrowserTabId]: {
...browserSummary('conversation-a', firstBrowserTabId, true),
sessionActive: true,
canGoBack: true,
url: 'https://a.example/'
}
},
'conversation-b': {
[secondBrowserTabId]: {
...browserSummary('conversation-b', secondBrowserTabId, true),
sessionActive: true,
url: 'https://b.example/'
}
}
}
const props = {
tab: 'browser' as const,
activeConversationId: 'conversation-a',
browserStates,
onNavigateBrowser,
onBackBrowser,
onReloadBrowser,
onStopLoadingBrowser
}
const view = render(sidebarElement(props))
await waitFor(() =>
expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/')
)
const browserA = screen.getByRole('tab', { name: '浏览器 · conversation-a' })
const ownerA = browserApi.createTab.mock.calls[0]![0].workbarInstanceId
view.rerender(sidebarElement({ ...props, activeConversationId: 'conversation-b' }))
expect(browserA).toBeVisible()
expect(browserA).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/')
expect(browserApi.createTab).toHaveBeenCalledOnce()
fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), {
target: { value: 'https://a.example/next' }
})
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() => expect(onNavigateBrowser).toHaveBeenCalledWith(
'conversation-a', firstBrowserTabId, 'https://a.example/next'
))
fireEvent.click(screen.getByRole('button', { name: '返回' }))
await waitFor(() => {
expect(onBackBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId)
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
})
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
await waitFor(() => {
expect(onReloadBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId)
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
})
view.rerender(sidebarElement({
...props,
activeConversationId: 'conversation-b',
browserStates: {
...browserStates,
'conversation-a': {
[firstBrowserTabId]: { ...browserStates['conversation-a'][firstBrowserTabId]!, isLoading: true }
}
}
}))
fireEvent.click(screen.getByRole('button', { name: '停止加载' }))
await waitFor(() => expect(onStopLoadingBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId))
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!)
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
expect(browserApi.createTab).toHaveBeenLastCalledWith({
conversationId: 'conversation-b',
workbarInstanceId: expect.any(String)
})
expect(browserApi.createTab.mock.calls[1]![0].workbarInstanceId).not.toBe(ownerA)
expect(screen.getByRole('tab', { name: '浏览器 · conversation-b' })).toHaveAttribute('aria-selected', 'true')
expect(browserA).toBeVisible()
await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://b.example/'))
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() => expect(onNavigateBrowser).toHaveBeenLastCalledWith(
'conversation-b', secondBrowserTabId, 'https://b.example/'
))
fireEvent.click(browserA)
expect(browserA).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/')
expect(browserApi.createTab).toHaveBeenCalledTimes(2)
})
it('keeps two browser instances independent and closes only the selected Main tab', async () => {
const conversationId = 'conversation-a'
const onNavigateBrowser = vi.fn(async () => undefined)
const states: Record<string, Record<string, BrowserLiveState>> = {
[conversationId]: {
[firstBrowserTabId]: {
conversationId,
tabId: firstBrowserTabId,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
url: 'https://first.example/',
updatedAt: 1
},
[secondBrowserTabId]: {
conversationId,
tabId: secondBrowserTabId,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
url: 'https://second.example/',
updatedAt: 2
}
}
}
renderSidebar({
tab: 'browser',
activeConversationId: conversationId,
browserStates: states,
onNavigateBrowser
})
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
const firstAddress = screen.getByRole('textbox', {
name: '浏览器地址'
})
await waitFor(() =>
expect(firstAddress).toHaveValue('https://first.example/')
)
fireEvent.change(firstAddress, {
target: { value: 'https://first.example/next' }
})
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() =>
expect(onNavigateBrowser).toHaveBeenCalledWith(
conversationId,
firstBrowserTabId,
'https://first.example/next'
)
)
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
fireEvent.click(
screen.getByText('浏览器', { selector: 'strong' }).closest('button')!
)
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
const ownershipRequests = browserApi.createTab.mock.calls.map(
([request]) => request
)
expect(ownershipRequests[0]?.conversationId).toBe(conversationId)
expect(ownershipRequests[1]?.conversationId).toBe(conversationId)
expect(ownershipRequests[0]?.workbarInstanceId).not.toBe(
ownershipRequests[1]?.workbarInstanceId
)
const secondAddress = screen.getByRole('textbox', {
name: '浏览器地址'
})
await waitFor(() =>
expect(secondAddress).toHaveValue('https://second.example/')
)
fireEvent.change(secondAddress, {
target: { value: 'https://second.example/next' }
})
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() =>
expect(onNavigateBrowser).toHaveBeenLastCalledWith(
conversationId,
secondBrowserTabId,
'https://second.example/next'
)
)
fireEvent.click(
screen.getByRole('button', { name: /关闭浏览器 · conversation-a · 2/u })
)
await waitFor(() =>
expect(browserApi.closeTab).toHaveBeenCalledWith({
conversationId,
tabId: secondBrowserTabId
})
)
expect(
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
).toBeInTheDocument()
expect(screen.queryByRole('tab', { name: /· 2$/u })).not.toBeInTheDocument()
})
it('restores a browser by stable ownership and releases only its viewport lease', async () => {
const conversationId = 'conversation-restored'
const workbarInstanceId = '10000000-0000-4000-8000-000000000003'
localStorage.setItem(
'goodbuddy.workbar-layout.v1',
JSON.stringify({
instances: [
{
id: '10000000-0000-4000-8000-000000000001',
appId: 'tasks',
title: '任务中心'
},
{
id: '10000000-0000-4000-8000-000000000002',
appId: 'workspace',
title: '工作区'
},
{
id: '10000000-0000-4000-8000-000000000003',
appId: 'browser',
title: '浏览器 · 已恢复',
targetRef: { type: 'conversation', conversationId }
}
],
activeInstanceId: '10000000-0000-4000-8000-000000000001',
expanded: true,
dock: 'right',
widthRatio: 0.3,
taskScope: 'current-project'
})
)
const bounds = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockReturnValue({
bottom: 500,
height: 400,
left: 900,
right: 1300,
top: 100,
width: 400,
x: 900,
y: 100,
toJSON: () => ({})
})
try {
renderSidebar({ activeConversationId: 'different-conversation' })
expect(browserApi.createTab).not.toHaveBeenCalled()
fireEvent.click(
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
)
await waitFor(() =>
expect(browserApi.createTab).toHaveBeenCalledWith({
conversationId,
workbarInstanceId
})
)
await waitFor(() =>
expect(browserApi.setViewport).toHaveBeenCalledWith(
expect.objectContaining({
conversationId,
tabId: firstBrowserTabId,
bounds: expect.any(Object),
leaseToken: expect.any(String)
})
)
)
const acquired = browserApi.setViewport.mock.calls.find(
([request]) => 'conversationId' in request
)?.[0]
if (!acquired || !('conversationId' in acquired)) {
throw new Error('Expected an acquired viewport lease')
}
expect(acquired.leaseToken).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
)
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
await waitFor(() =>
expect(browserApi.setViewport).toHaveBeenCalledWith({
leaseToken: acquired.leaseToken
})
)
expect(
browserApi.setViewport.mock.calls.some(
([request]) =>
Object.keys(request).length === 1 &&
request.leaseToken !== acquired.leaseToken
)
).toBe(false)
fireEvent.click(
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
)
await waitFor(() => {
const acquisitions = browserApi.setViewport.mock.calls
.map(([request]) => request)
.filter((request) => 'conversationId' in request)
expect(acquisitions).toHaveLength(2)
expect(acquisitions[1]!.leaseToken).not.toBe(
acquisitions[0]!.leaseToken
)
})
cleanup()
renderSidebar({ activeConversationId: 'different-conversation' })
fireEvent.click(
screen.getByRole('tab', { name: `浏览器 · ${conversationId}` })
)
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
expect(browserApi.createTab).toHaveBeenLastCalledWith({
conversationId,
workbarInstanceId
})
await waitFor(() =>
expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({
conversationId,
tabId: firstBrowserTabId
})
)
)
} finally {
bounds.mockRestore()
}
})
it('keeps a browser workbar tab when closing its Main tab fails', async () => {
browserApi.closeTab.mockRejectedValueOnce(new Error('Close denied'))
renderSidebar({ tab: 'browser', activeConversationId: 'conversation-a' })
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
fireEvent.click(
screen.getByRole('button', { name: /关闭浏览器 · conversation-a/u })
)
expect(await screen.findByRole('alert')).toHaveTextContent('Close denied')
expect(
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
).toBeInTheDocument()
})
})
+93 -26
View File
@@ -39,12 +39,14 @@ const stylesheet = readFileSync(
function ControlledShell({
initialInstances = DEFAULT_WORKBAR_INSTANCES,
onCreate = vi.fn(),
onClose,
onTerminalTarget = vi.fn((): WorkbarTargetRef => ({
type: 'local'
}))
}: {
initialInstances?: readonly WorkbarTabInstance[]
onCreate?: (request: WorkbarInstanceCreateRequest) => void
onClose?: (instance: WorkbarTabInstance) => boolean | void | Promise<boolean | void>
onTerminalTarget?: () =>
| WorkbarTargetRef
| Promise<WorkbarTargetRef>
@@ -55,19 +57,27 @@ function ControlledShell({
)
return (
<WorkbarShell
activeInstanceId={activeId}
instances={instances}
onActiveInstanceChange={setActiveId}
onCloseInstance={(instance) =>
setInstances((current) =>
current.filter((candidate) => candidate.id !== instance.id)
)
}
onCreateInstance={onCreate}
onResolveTerminalTarget={onTerminalTarget}
renderPanel={(instance) => <p>{instance.title}内容</p>}
/>
<>
<output data-testid="active-workbar-instance">
{activeId ?? 'none'}
</output>
<WorkbarShell
activeInstanceId={activeId}
instances={instances}
onActiveInstanceChange={setActiveId}
onCloseInstance={async (instance) => {
const accepted = await onClose?.(instance)
if (accepted === false) return false
setInstances((current) =>
current.filter((candidate) => candidate.id !== instance.id)
)
return true
}}
onCreateInstance={onCreate}
onResolveTerminalTarget={onTerminalTarget}
renderPanel={(instance) => <p>{instance.title}内容</p>}
/>
</>
)
}
@@ -87,7 +97,7 @@ describe('WorkbarShell', () => {
)
})
it('provides four default single-instance tabs and keeps add outside the tablist', () => {
it('provides fixed defaults plus a browser tab and keeps add outside the tablist', () => {
render(<ControlledShell />)
const tablist = screen.getByRole('tablist', {
@@ -113,6 +123,20 @@ describe('WorkbarShell', () => {
).not.toHaveClass('workbar-shell__tab-item--active')
})
it('does not expose close controls for fixed tabs', () => {
render(<ControlledShell />)
expect(
screen.queryByRole('button', { name: '关闭任务中心' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '关闭工作区' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: '关闭浏览器' })
).toBeInTheDocument()
})
it('opens the catalog, focuses its first choice, and restores add-button focus on Escape', async () => {
render(<ControlledShell />)
const addButton = screen.getByRole('button', {
@@ -196,6 +220,22 @@ describe('WorkbarShell', () => {
)
})
it('allows another browser instance to be requested', () => {
const onCreate = vi.fn()
render(<ControlledShell onCreate={onCreate} />)
fireEvent.click(
screen.getByRole('button', { name: '打开工作栏应用' })
)
fireEvent.click(
screen.getByText('浏览器', { selector: 'strong' }).closest('button')!
)
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ appId: 'browser' })
)
})
it('supports roving Arrow, Home, and End navigation', () => {
render(<ControlledShell />)
const tasks = screen.getByRole('tab', { name: '任务中心' })
@@ -214,19 +254,23 @@ describe('WorkbarShell', () => {
expect(tasks).toHaveFocus()
})
it('closes the active tab, activates its right neighbor, and restores focus', async () => {
render(<ControlledShell />)
const workspace = screen.getByRole('tab', { name: '工作区' })
fireEvent.click(workspace)
it('closes an active closable tab, activates its neighbor, and restores focus', async () => {
render(
<ControlledShell
initialInstances={[...DEFAULT_WORKBAR_INSTANCES, terminalOne]}
/>
)
const browser = screen.getByRole('tab', { name: '浏览器' })
fireEvent.click(browser)
fireEvent.click(
screen.getByRole('button', { name: '关闭工作区' })
screen.getByRole('button', { name: '关闭浏览器' })
)
const browser = screen.getByRole('tab', { name: '浏览器' })
await waitFor(() => expect(browser).toHaveFocus())
expect(browser).toHaveAttribute('aria-selected', 'true')
const results = screen.getByRole('tab', { name: '成果' })
await waitFor(() => expect(results).toHaveFocus())
expect(results).toHaveAttribute('aria-selected', 'true')
expect(
screen.queryByRole('tab', { name: '工作区' })
screen.queryByRole('tab', { name: '浏览器' })
).not.toBeInTheDocument()
})
@@ -234,19 +278,39 @@ describe('WorkbarShell', () => {
render(<ControlledShell />)
const tasks = screen.getByRole('tab', { name: '任务中心' })
fireEvent.click(
screen.getByRole('button', { name: '关闭成果' })
screen.getByRole('button', { name: '关闭浏览器' })
)
await waitFor(() => expect(tasks).toHaveFocus())
expect(tasks).toHaveAttribute('aria-selected', 'true')
})
it('retains an instance when its close operation rejects', async () => {
const onClose = vi.fn(async () => {
throw new Error('Close denied')
})
render(
<ControlledShell
initialInstances={[terminalOne]}
onClose={onClose}
/>
)
fireEvent.click(
screen.getByRole('button', { name: `关闭${terminalOne.title}` })
)
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
expect(screen.getByRole('tab', { name: terminalOne.title }))
.toBeInTheDocument()
})
it('shows the empty state while preserving the add command after the last close', async () => {
render(
<ControlledShell initialInstances={[DEFAULT_WORKBAR_INSTANCES[0]!]} />
<ControlledShell initialInstances={[terminalOne]} />
)
fireEvent.click(
screen.getByRole('button', { name: '关闭任务中心' })
screen.getByRole('button', { name: `关闭${terminalOne.title}` })
)
const addButton = screen.getByRole('button', {
@@ -256,6 +320,9 @@ describe('WorkbarShell', () => {
expect(
screen.getByText('还没有打开的工作栏应用')
).toBeInTheDocument()
expect(screen.getByTestId('active-workbar-instance')).toHaveTextContent(
'none'
)
expect(addButton).toBeVisible()
})
})
+34 -23
View File
@@ -61,7 +61,7 @@ export type WorkbarShellProps = {
activeInstanceId: string | null
renderPanel: (instance: WorkbarTabInstance) => ReactNode
renderTabAdornment?: (instance: WorkbarTabInstance) => ReactNode
onActiveInstanceChange: (instanceId: string) => void
onActiveInstanceChange: (instanceId: string | null) => void
onCreateInstance: (request: WorkbarInstanceCreateRequest) => void
onCloseInstance: (
instance: WorkbarTabInstance
@@ -289,6 +289,12 @@ export function WorkbarShell({
const handleClose = async (
instance: WorkbarTabInstance
): Promise<void> => {
const definition = appDefinitions.find(
(candidate) => candidate.id === instance.appId
)
if (definition?.closable !== true) {
return
}
const closingIndex = instances.findIndex(
(candidate) => candidate.id === instance.id
)
@@ -299,19 +305,18 @@ export function WorkbarShell({
null)
: activeInstance
const closeResult = onCloseInstance(instance)
const closeAccepted =
closeResult &&
typeof (closeResult as PromiseLike<boolean | void>).then ===
'function'
? await closeResult
: closeResult
let closeAccepted: boolean | void
try {
closeAccepted = await onCloseInstance(instance)
} catch {
return
}
if (closeAccepted === false) {
return
}
pendingTabFocusRef.current = nextActive?.id ?? null
if (nextActive && nextActive.id !== activeInstanceId) {
onActiveInstanceChange(nextActive.id)
if (isActive && nextActive?.id !== activeInstanceId) {
onActiveInstanceChange(nextActive?.id ?? null)
}
}
@@ -331,6 +336,10 @@ export function WorkbarShell({
const selected =
!catalogOpen && instance.id === activeInstanceId
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
const closable =
appDefinitions.find(
(definition) => definition.id === instance.appId
)?.closable === true
return (
<div
className={joinClassNames(
@@ -376,19 +385,21 @@ export function WorkbarShell({
</span>
{renderTabAdornment?.(instance)}
</button>
<button
aria-label={t('sidebar.workbar.close', {
title: instance.title
})}
className="workbar-shell__tab-close"
onClick={() => void handleClose(instance)}
title={t('sidebar.workbar.close', {
title: instance.title
})}
type="button"
>
<X aria-hidden="true" />
</button>
{closable ? (
<button
aria-label={t('sidebar.workbar.close', {
title: instance.title
})}
className="workbar-shell__tab-close"
onClick={() => void handleClose(instance)}
title={t('sidebar.workbar.close', {
title: instance.title
})}
type="button"
>
<X aria-hidden="true" />
</button>
) : null}
</div>
)
})}
@@ -208,6 +208,9 @@ export const app = {
switching: 'Switching…',
picker: 'Runtime and model',
directModels: 'Direct models',
defaultDirect: 'Direct · Follow the global default model',
fixedRuntimeModel: 'Fixed Runtime model',
fixedModel: 'Fixed',
deepseekHarnessGroup:
'DeepSeek Harness (Developer preview · OpenAI-compatible)',
manage: 'Manage Runtime and model connections',
@@ -310,6 +310,14 @@ export const workspace = {
startedAt: 'Started {{time}}',
nextRunAt: 'Next run: {{time}}',
notStarted: 'Not run yet',
scope: {
ariaLabel: 'Task scope',
currentProject: 'Current project',
global: 'Global',
allProjects: 'All projects',
noCurrentProject:
'No project is active. Choose another task scope or open a project.'
},
filters: {
ariaLabel: 'Filter tasks',
attention: 'Attention',
@@ -365,6 +373,7 @@ export const workspace = {
},
browser: {
title: 'Live browser',
unbound: 'No conversation',
interact: 'Interact',
interacting: 'Interacting',
close: 'Close browser',
@@ -407,6 +416,8 @@ export const workspace = {
backBrowser: 'Could not go back',
reloadBrowser: 'Could not refresh the web page',
stopLoadingBrowser: 'Could not stop loading the page',
closeBrowser: 'Could not close this browser tab',
browserControlUnavailable: 'Browser tab controls are unavailable',
interactBrowser: 'Could not open the browser interaction window',
stopBrowser: 'Could not close the browser'
}
@@ -195,6 +195,9 @@ export const app = {
switching: '切换中…',
picker: 'Runtime 和模型',
directModels: '直连模型',
defaultDirect: '直连 · 跟随全局默认模型',
fixedRuntimeModel: '固定 Runtime 模型',
fixedModel: '固定',
deepseekHarnessGroup:
'DeepSeek Harness(开发者预览 · OpenAI 兼容)',
manage: '管理 Runtime 和模型连接',
@@ -302,6 +302,13 @@ export const workspace = {
startedAt: '{{time}} 开始',
nextRunAt: '下次运行:{{time}}',
notStarted: '尚未运行',
scope: {
ariaLabel: '任务范围',
currentProject: '当前项目',
global: '全局',
allProjects: '所有项目',
noCurrentProject: '没有活动项目。请选择其他任务范围或先打开一个项目。'
},
filters: {
ariaLabel: '筛选任务',
attention: '待关注',
@@ -355,6 +362,7 @@ export const workspace = {
},
browser: {
title: '实时浏览器',
unbound: '未绑定会话',
interact: '交互',
interacting: '交互中',
close: '关闭浏览器',
@@ -397,6 +405,8 @@ export const workspace = {
backBrowser: '返回上一页失败',
reloadBrowser: '刷新网页失败',
stopLoadingBrowser: '停止加载失败',
closeBrowser: '关闭此浏览器 Tab 失败',
browserControlUnavailable: '浏览器 Tab 控制不可用',
interactBrowser: '打开浏览器交互窗口失败',
stopBrowser: '关闭浏览器失败'
}
+2 -3
View File
@@ -17,7 +17,7 @@ function harnessSettings(
}
describe('DeepSeek Harness runtime selection', () => {
it('uses the configured Chat Completions profile', () => {
it('references the Runtime configuration without copying its profile', () => {
const selection = getRuntimeSelectionForProvider(
'deepseek-harness',
harnessSettings({
@@ -27,8 +27,7 @@ describe('DeepSeek Harness runtime selection', () => {
)
expect(selection).toEqual({
provider: 'deepseek-harness',
profileId: harnessProfileId
provider: 'deepseek-harness'
} satisfies Record<string, string>)
})
+52
View File
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
agentRequestSchema,
browserCloseTabRequestSchema,
browserCreateTabRequestSchema,
browserLiveStateSchema,
browserNavigateRequestSchema,
browserSetViewportRequestSchema,
browserStopLoadingRequestSchema,
builtinEmbeddingConnectionId,
clipboardTextSchema,
@@ -23,9 +26,14 @@ describe('clipboard text contract', () => {
})
describe('browser control contracts', () => {
const tabId = '4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251'
const leaseToken = '7d201980-0ad4-4670-81d4-dc2bf79f03b2'
it('requires authoritative toolbar metadata and accepts committed URLs up to 8192 characters', () => {
const base = {
conversationId: 'conversation',
tabId,
status: 'ready' as const,
sessionActive: true,
isLoading: false,
@@ -51,10 +59,12 @@ describe('browser control contracts', () => {
expect(
browserNavigateRequestSchema.parse({
conversationId: 'conversation',
tabId,
url: 'https://example.com/'
})
).toEqual({
conversationId: 'conversation',
tabId,
url: 'https://example.com/'
})
expect(
@@ -70,6 +80,48 @@ describe('browser control contracts', () => {
}).success
).toBe(true)
})
it('uses opaque tab IDs and validates complete viewport ownership leases', () => {
expect(
browserCreateTabRequestSchema.parse({
conversationId: 'conversation',
workbarInstanceId
})
).toEqual({ conversationId: 'conversation', workbarInstanceId })
expect(
browserCloseTabRequestSchema.safeParse({
conversationId: 'conversation',
tabId
}).success
).toBe(true)
expect(
browserCloseTabRequestSchema.safeParse({
conversationId: 'conversation',
tabId: 'predictable-tab-name'
}).success
).toBe(false)
expect(
browserSetViewportRequestSchema.safeParse({
conversationId: 'conversation',
tabId,
leaseToken,
bounds: { x: 1, y: 2, width: 300, height: 400 }
}).success
).toBe(true)
expect(
browserSetViewportRequestSchema.safeParse({ tabId, leaseToken }).success
).toBe(false)
expect(
browserLiveStateSchema.safeParse({
conversationId: 'conversation',
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
updatedAt: 1
}).success
).toBe(false)
})
})
const baseInput = {
+185 -37
View File
@@ -30,8 +30,6 @@ import {
actorConversationSubagentActivitySchema,
legacyConversationSubagentActivitySchema,
legacyWorkModeSchema,
maximumConversationHistoryCharacters,
maximumConversationHistoryMessages,
type AssistantProject,
type AssistantArtifact,
type AssistantMemory,
@@ -346,11 +344,9 @@ export const agentRequestSchema = z
contextIds: z.array(z.string().uuid()).max(8).optional(),
history: z
.array(conversationHistoryMessageSchema)
.max(maximumConversationHistoryMessages)
.optional(),
historyMessageIds: z
.array(z.string().uuid())
.max(maximumConversationHistoryMessages)
.optional(),
currentUserMessageId: z.string().uuid().optional(),
currentAssistantMessageId: z.string().uuid().optional(),
@@ -359,18 +355,6 @@ export const agentRequestSchema = z
})
.strict()
.superRefine((request, context) => {
const historyLength =
request.history?.reduce(
(total, message) => total + message.content.length,
0
) ?? 0
if (historyLength > maximumConversationHistoryCharacters) {
context.addIssue({
code: 'custom',
path: ['history'],
message: `会话历史总长度不能超过 ${maximumConversationHistoryCharacters.toLocaleString()} 个字符`
})
}
if (
request.historyMessageIds &&
request.historyMessageIds.length !==
@@ -683,6 +667,7 @@ const embeddingConnectionInputSchema = z.discriminatedUnion('kind', [
])
export const runtimeModelSourceSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('default') }).strict(),
z.object({ kind: z.literal('platform') }).strict(),
z
.object({
@@ -1373,18 +1358,28 @@ export type AppInfo = {
shortcutStatus?: GlobalShortcutRegistrationStatus
}
export const browserTabIdSchema = z.string().uuid().brand<'BrowserTabId'>()
export type BrowserTabId = z.infer<typeof browserTabIdSchema>
export const browserWorkbarInstanceIdSchema = z.string().uuid()
export const browserViewportLeaseTokenSchema = z.string().uuid()
const browserStatusSchema = z.enum([
'creating',
'loading',
'ready',
'acting',
'interactive',
'failed',
'stopped'
])
export const browserLiveStateSchema = z
.object({
conversationId: conversationIdSchema,
status: z.enum([
'creating',
'loading',
'ready',
'acting',
'interactive',
'failed',
'stopped'
]),
tabId: browserTabIdSchema,
ownerWindowId: z.number().int().nonnegative().optional(),
status: browserStatusSchema,
sessionActive: z.boolean(),
isLoading: z.boolean(),
canGoBack: z.boolean(),
@@ -1404,19 +1399,73 @@ export const browserLiveStateSchema = z
export type BrowserLiveState = z.infer<typeof browserLiveStateSchema>
export const browserTabSummarySchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema,
primary: z.boolean(),
status: browserStatusSchema.exclude(['stopped']),
isLoading: z.boolean(),
canGoBack: z.boolean(),
url: z.string().max(8_192).optional(),
createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative()
})
.strict()
export type BrowserTabSummary = z.infer<typeof browserTabSummarySchema>
export const browserCreateTabRequestSchema = z
.object({
conversationId: conversationIdSchema,
workbarInstanceId: browserWorkbarInstanceIdSchema
})
.strict()
export type BrowserCreateTabRequest = z.infer<typeof browserCreateTabRequestSchema>
export const browserListTabsRequestSchema = z
.object({ conversationId: conversationIdSchema })
.strict()
export type BrowserListTabsRequest = z.infer<typeof browserListTabsRequestSchema>
export const browserTabRequestSchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema
})
.strict()
export type BrowserTabRequest = z.infer<typeof browserTabRequestSchema>
export const browserCloseTabRequestSchema = browserTabRequestSchema
export type BrowserCloseTabRequest = BrowserTabRequest
export const browserStopRequestSchema = z
.object({
conversationId: conversationIdSchema
})
.strict()
export const browserBackRequestSchema = browserStopRequestSchema
export const browserReloadRequestSchema = browserStopRequestSchema
export const browserStopLoadingRequestSchema = browserStopRequestSchema
const browserCompatibleTabRequestSchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema.optional()
})
.strict()
export const browserBackTabRequestSchema = browserCompatibleTabRequestSchema
export const browserReloadTabRequestSchema = browserCompatibleTabRequestSchema
export const browserStopLoadingTabRequestSchema = browserCompatibleTabRequestSchema
export const browserBackRequestSchema = browserBackTabRequestSchema
export const browserReloadRequestSchema = browserReloadTabRequestSchema
export const browserStopLoadingRequestSchema = browserStopLoadingTabRequestSchema
export const browserSnapshotRequestSchema = browserCompatibleTabRequestSchema
export const browserScreenshotRequestSchema = browserCompatibleTabRequestSchema
export const browserSetViewportRequestSchema = z
.object({
conversationId: conversationIdSchema.optional(),
tabId: browserTabIdSchema.optional(),
leaseToken: browserViewportLeaseTokenSchema,
bounds: z
.object({
x: z.number().int().nonnegative(),
@@ -1429,7 +1478,9 @@ export const browserSetViewportRequestSchema = z
})
.strict()
.refine(
(value) => Boolean(value.conversationId) === Boolean(value.bounds),
(value) =>
(!value.conversationId && !value.tabId && !value.bounds) ||
Boolean(value.conversationId && value.bounds),
'浏览器视口参数不完整'
)
@@ -1440,10 +1491,90 @@ export type BrowserViewportBounds = NonNullable<
export const browserNavigateRequestSchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema.optional(),
url: z.string().min(1).max(8_192)
})
.strict()
export type BrowserNavigateRequest = Omit<
z.infer<typeof browserNavigateRequestSchema>,
'tabId'
> & { tabId: BrowserTabId }
export const browserClickRequestSchema = browserCompatibleTabRequestSchema
.extend({ ref: z.string().min(1).max(256) })
.strict()
export const browserTypeRequestSchema = browserCompatibleTabRequestSchema
.extend({
ref: z.string().min(1).max(256),
text: z.string().max(16_384)
})
.strict()
export const browserSelectRequestSchema = browserCompatibleTabRequestSchema
.extend({
ref: z.string().min(1).max(256),
value: z.string().max(1_024)
})
.strict()
type BrowserOwnedRequest<T extends { tabId?: BrowserTabId }> = Omit<T, 'tabId'> & {
tabId: BrowserTabId
}
export type BrowserBackRequest = BrowserOwnedRequest<
z.infer<typeof browserBackTabRequestSchema>
>
export type BrowserReloadRequest = BrowserOwnedRequest<
z.infer<typeof browserReloadTabRequestSchema>
>
export type BrowserStopLoadingRequest = BrowserOwnedRequest<
z.infer<typeof browserStopLoadingTabRequestSchema>
>
export type BrowserSnapshotRequest = BrowserOwnedRequest<
z.infer<typeof browserSnapshotRequestSchema>
>
export type BrowserScreenshotRequest = BrowserOwnedRequest<
z.infer<typeof browserScreenshotRequestSchema>
>
export type BrowserClickRequest = BrowserOwnedRequest<
z.infer<typeof browserClickRequestSchema>
>
export type BrowserTypeRequest = BrowserOwnedRequest<
z.infer<typeof browserTypeRequestSchema>
>
export type BrowserSelectRequest = BrowserOwnedRequest<
z.infer<typeof browserSelectRequestSchema>
>
export type BrowserSetViewportRequest =
| {
conversationId: string
tabId: BrowserTabId
bounds: BrowserViewportBounds
leaseToken: string
}
| { leaseToken: string }
export type BrowserSnapshotResult = {
url: string
title: string
nodes: Array<{
ref: string
role: string
name: string
value?: string
disabled?: boolean
focused?: boolean
editable?: boolean
}>
truncated: boolean
}
export type BrowserScreenshotResult = {
type: 'image'
mimeType: 'image/jpeg'
data: string
}
export const knowledgeIdSchema = z.string().uuid()
export const knowledgeCreateSchema = z
.object({
@@ -1637,14 +1768,31 @@ export type DesktopApi = {
onEvent: (listener: (event: AgentEvent) => void) => () => void
}
browser: {
navigate: (conversationId: string, url: string) => Promise<void>
back: (conversationId: string) => Promise<void>
reload: (conversationId: string) => Promise<void>
stopLoading: (conversationId: string) => Promise<void>
setViewport: (
conversationId?: string,
bounds?: BrowserViewportBounds
) => Promise<void>
createTab?: (request: BrowserCreateTabRequest) => Promise<BrowserTabSummary>
listTabs?: (request: BrowserListTabsRequest) => Promise<BrowserTabSummary[]>
closeTab?: (request: BrowserCloseTabRequest) => Promise<void>
navigate: {
(request: BrowserNavigateRequest): Promise<void>
(conversationId: string, url: string): Promise<void>
}
back: {
(request: BrowserBackRequest): Promise<void>
(conversationId: string): Promise<void>
}
reload: {
(request: BrowserReloadRequest): Promise<void>
(conversationId: string): Promise<void>
}
stopLoading: {
(request: BrowserStopLoadingRequest): Promise<void>
(conversationId: string): Promise<void>
}
snapshot?: (request: BrowserSnapshotRequest) => Promise<BrowserSnapshotResult>
click?: (request: BrowserClickRequest) => Promise<void>
type?: (request: BrowserTypeRequest) => Promise<void>
select?: (request: BrowserSelectRequest) => Promise<void>
screenshot?: (request: BrowserScreenshotRequest) => Promise<BrowserScreenshotResult>
setViewport: (request: BrowserSetViewportRequest) => Promise<void>
stop: (conversationId: string) => Promise<void>
onState: (listener: (state: BrowserLiveState) => void) => () => void
}
+8
View File
@@ -22,10 +22,18 @@ export const ipcChannels = {
agentQuestionRespond: 'agent:question:respond',
agentCompactConversation: 'agent:context:compact',
agentEvent: 'agent:event',
browserCreateTab: 'browser:tab:create',
browserListTabs: 'browser:tab:list',
browserCloseTab: 'browser:tab:close',
browserNavigate: 'browser:navigate',
browserBack: 'browser:back',
browserReload: 'browser:reload',
browserStopLoading: 'browser:stop-loading',
browserSnapshot: 'browser:snapshot',
browserClick: 'browser:click',
browserType: 'browser:type',
browserSelect: 'browser:select',
browserScreenshot: 'browser:screenshot',
browserSetViewport: 'browser:set-viewport',
browserStop: 'browser:stop',
browserState: 'browser:state',
+26 -51
View File
@@ -10,7 +10,7 @@ export const agentRuntimeSelectionSchema = z.discriminatedUnion(
z
.object({
provider: z.literal('model'),
profileId: runtimeSelectionProfileIdSchema
profileId: runtimeSelectionProfileIdSchema.optional()
})
.strict(),
z
@@ -48,17 +48,21 @@ export type RuntimeSelectionRepairSettings = {
}>
defaultModelProfileId: string
opencodeModelSource:
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
continueModelSource:
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
deepseekHarnessModelSource?:
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
}
type RuntimeModelSource =
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
@@ -79,34 +83,9 @@ type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][numbe
export function getRuntimeSelectionForProvider(
provider: Exclude<AgentRuntimeSelection['provider'], 'auto'>,
settings: RuntimeSelectionDefaultSettings
_settings: RuntimeSelectionDefaultSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: provider === 'continue'
? settings.continueModelSource
: settings.deepseekHarnessModelSource
const resolvedProfile =
provider === 'opencode'
? settings.opencodeModelProfile
: provider === 'continue'
? settings.continueModelProfile
: settings.deepseekHarnessModelProfile
return {
provider,
...(source?.kind === 'profile'
? { profileId: source.profileId }
: !source && resolvedProfile
? { profileId: resolvedProfile.id }
: {})
}
return { provider }
}
export function getDefaultRuntimeSelection(
@@ -121,6 +100,20 @@ export function getDefaultRuntimeSelection(
: getRuntimeSelectionForProvider('model', settings)
}
// Resolve only for presentation or request diagnostics, never for persisted defaults.
export function getRuntimeSelectionProfileId(
selection: AgentRuntimeSelection,
settings: RuntimeSelectionDefaultSettings
): string | undefined {
if (selection.provider === 'auto') return undefined
if (selection.profileId) return selection.profileId
if (selection.provider === 'model') return settings.defaultModelProfileId
const source = selection.provider === 'opencode' ? settings.opencodeModelSource
: selection.provider === 'continue' ? settings.continueModelSource : settings.deepseekHarnessModelSource
return source?.kind === 'default' ? settings.defaultModelProfileId
: source?.kind === 'profile' ? source.profileId : undefined
}
export function isChannelModelProfileUsable(
profile: ChannelModelProfile
): boolean {
@@ -162,11 +155,7 @@ export function repairChannelRuntimeSelection(
isChannelModelProfileUsable(profile)
) ??
settings.modelProfiles.find(isChannelModelProfileUsable)
const defaultDirectSelection: AgentRuntimeSelection = {
provider: 'model',
profileId:
defaultDirectProfile?.id ?? settings.defaultModelProfileId
}
const defaultDirectSelection: AgentRuntimeSelection = { provider: 'model' }
if (selection.provider === 'auto') {
return defaultDirectSelection
}
@@ -196,7 +185,7 @@ export function repairChannelRuntimeSelection(
return repaired
}
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === repaired.profileId
(candidate) => candidate.id === (repaired.profileId ?? defaultDirectProfile?.id)
)
return profile && isChannelModelProfileUsable(profile)
? repaired
@@ -217,29 +206,15 @@ export function repairAgentRuntimeSelection(
return selection
}
if (selection.provider === 'model') {
return {
provider: 'model',
profileId: settings.defaultModelProfileId
}
}
const source =
selection.provider === 'opencode'
? settings.opencodeModelSource
: selection.provider === 'continue'
? settings.continueModelSource
: settings.deepseekHarnessModelSource ?? { kind: 'platform' }
return {
provider: selection.provider,
...(source.kind === 'profile'
? { profileId: source.profileId }
: {})
return { provider: 'model' }
}
return { provider: selection.provider }
}
export function agentRuntimeSelectionKey(
selection: AgentRuntimeSelection
): string {
return `${selection.provider}:${'profileId' in selection
? selection.profileId ?? 'platform'
? selection.profileId ?? 'default'
: 'default'}`
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { workbarTargetRefSchema } from './workbar-contracts'
import { workbarExecutionTargetRefSchema } from './workbar-contracts'
export const TERMINAL_LIMITS = {
maximumSessionsPerWindow: 12,
@@ -35,7 +35,7 @@ export type TerminalSessionId = z.infer<
typeof terminalSessionIdSchema
>
export const terminalTargetSchema = workbarTargetRefSchema
export const terminalTargetSchema = workbarExecutionTargetRefSchema
export type TerminalTarget = z.infer<typeof terminalTargetSchema>
export const terminalSessionStateSchema = z.enum([
+116 -9
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
WORKBAR_APP_DEFINITIONS,
WORKBAR_LIMITS,
normalizeWorkbarLayoutPreferences,
workbarAppDefinitionSchema,
workbarLayoutPreferencesSchema,
workbarTabInstanceSchema
@@ -14,20 +15,28 @@ const projectId = '00000000-0000-4000-8000-000000000201'
describe('workbar contracts', () => {
it('freezes the first application catalog and instance policies', () => {
expect(
WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultOpen }) => ({
WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultContext, defaultOpen, required, closable, reorderable }) => ({
id,
instancePolicy,
defaultOpen
defaultContext,
defaultOpen,
required,
closable,
reorderable
}))
).toEqual([
{ id: 'tasks', instancePolicy: 'single', defaultOpen: true },
{ id: 'workspace', instancePolicy: 'single', defaultOpen: true },
{ id: 'browser', instancePolicy: 'single', defaultOpen: true },
{ id: 'results', instancePolicy: 'single', defaultOpen: true },
{ id: 'tasks', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true },
{ id: 'workspace', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true },
{ id: 'browser', instancePolicy: 'multiple', defaultContext: 'current-conversation', defaultOpen: true, required: false, closable: true, reorderable: true },
{ id: 'results', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: false, closable: true, reorderable: true },
{
id: 'terminal',
instancePolicy: 'multiple',
defaultOpen: false
defaultContext: 'current-project',
defaultOpen: false,
required: false,
closable: true,
reorderable: true
}
])
for (const definition of WORKBAR_APP_DEFINITIONS) {
@@ -51,12 +60,52 @@ describe('workbar contracts', () => {
activeInstanceId: terminalId,
expanded: true,
dock: 'right',
widthRatio: 0.35
widthRatio: 0.35,
taskScope: 'current-project'
} as const
expect(workbarLayoutPreferencesSchema.parse(layout)).toEqual(layout)
})
it('persists a browser conversation binding without a runtime tab identifier', () => {
expect(
workbarTabInstanceSchema.parse({
id: '00000000-0000-4000-8000-000000000103',
appId: 'browser',
title: '浏览器 · 会话 A',
targetRef: { type: 'conversation', conversationId: 'conversation-a' }
})
).toEqual({
id: '00000000-0000-4000-8000-000000000103',
appId: 'browser',
title: '浏览器 · 会话 A',
targetRef: { type: 'conversation', conversationId: 'conversation-a' }
})
expect(
workbarTabInstanceSchema.safeParse({
id: '00000000-0000-4000-8000-000000000103',
appId: 'browser',
title: '浏览器',
targetRef: { type: 'conversation', conversationId: 'conversation-a' },
tabId: '00000000-0000-4000-8000-000000000999'
}).success
).toBe(false)
})
it('explicitly keeps every existing app visible across context switches', () => {
for (const definition of WORKBAR_APP_DEFINITIONS) {
expect(definition.visibleAcrossContextSwitches).toBe(true)
const { visibleAcrossContextSwitches, ...withoutVisibility } = definition
expect(workbarAppDefinitionSchema.safeParse(withoutVisibility).success).toBe(false)
expect(
workbarAppDefinitionSchema.parse({
...withoutVisibility,
visibleAcrossContextSwitches
}).visibleAcrossContextSwitches
).toBe(true)
}
})
it('rejects unknown and sensitive target parameters', () => {
for (const targetRef of [
{ type: 'local', cwd: 'C:\\secret' },
@@ -102,7 +151,8 @@ describe('workbar contracts', () => {
activeInstanceId: taskId,
expanded: true,
dock: 'right',
widthRatio: 0.4
widthRatio: 0.4,
taskScope: 'current-project'
}
expect(
workbarLayoutPreferencesSchema.safeParse({
@@ -131,4 +181,61 @@ describe('workbar contracts', () => {
}).success
).toBe(false)
})
it('normalizes duplicate policies, fixed defaults, and stale active state', () => {
const browserId = '00000000-0000-4000-8000-000000000103'
const secondBrowserId = '00000000-0000-4000-8000-000000000104'
const workspaceId = '00000000-0000-4000-8000-000000000105'
const normalized = normalizeWorkbarLayoutPreferences(
{
instances: [
{ id: browserId, appId: 'browser', title: '浏览器' },
{ id: secondBrowserId, appId: 'browser', title: '浏览器 2' },
{ id: workspaceId, appId: 'workspace', title: '工作区' },
{ id: terminalId, appId: 'workspace', title: '重复工作区' }
],
activeInstanceId: terminalId,
expanded: true,
dock: 'right',
widthRatio: 0.4,
taskScope: 'all-projects'
},
[
{ id: taskId, appId: 'tasks', title: '任务中心' },
{ id: workspaceId, appId: 'workspace', title: '工作区' }
]
)
expect(normalized?.instances.map(({ id, appId }) => ({ id, appId })))
.toEqual([
{ id: taskId, appId: 'tasks' },
{ id: browserId, appId: 'browser' },
{ id: secondBrowserId, appId: 'browser' },
{ id: workspaceId, appId: 'workspace' }
])
expect(normalized?.activeInstanceId).toBe(taskId)
expect(normalized?.taskScope).toBe('all-projects')
})
it('preserves a valid empty layout with a null active instance', () => {
expect(
normalizeWorkbarLayoutPreferences(
{
instances: [],
activeInstanceId: null,
expanded: false,
dock: 'right',
widthRatio: 0.4
},
[]
)
).toEqual({
instances: [],
activeInstanceId: null,
expanded: false,
dock: 'right',
widthRatio: 0.4,
taskScope: 'current-project'
})
})
})
+153 -7
View File
@@ -38,7 +38,8 @@ export type WorkbarInstancePolicy = z.infer<
export const workbarDefaultContextSchema = z.enum([
'application',
'current-project'
'current-project',
'current-conversation'
])
export type WorkbarDefaultContext = z.infer<
typeof workbarDefaultContextSchema
@@ -78,7 +79,11 @@ export const workbarAppDefinitionSchema = z
),
instancePolicy: workbarInstancePolicySchema,
defaultContext: workbarDefaultContextSchema,
visibleAcrossContextSwitches: z.boolean(),
defaultOpen: z.boolean(),
required: z.boolean(),
closable: z.boolean(),
reorderable: z.boolean(),
availability: workbarAvailabilitySchema
})
.strict()
@@ -93,57 +98,77 @@ export type WorkbarAppDefinition = z.infer<
export const WORKBAR_APP_DEFINITIONS = [
{
id: 'tasks',
visibleAcrossContextSwitches: true,
label: '任务中心',
icon: 'tasks',
description: '查看和管理当前任务。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
required: true,
closable: false,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'workspace',
visibleAcrossContextSwitches: true,
label: '工作区',
icon: 'workspace',
description: '查看当前项目的工作区。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
required: true,
closable: false,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'browser',
visibleAcrossContextSwitches: true,
label: '浏览器',
icon: 'browser',
description: '浏览任务相关内容。',
instancePolicy: 'single',
defaultContext: 'application',
instancePolicy: 'multiple',
defaultContext: 'current-conversation',
defaultOpen: true,
required: false,
closable: true,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'results',
visibleAcrossContextSwitches: true,
label: '成果',
icon: 'results',
description: '查看任务生成的成果。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
required: false,
closable: true,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'terminal',
visibleAcrossContextSwitches: true,
label: '终端',
icon: 'terminal',
description: '打开当前项目的用户终端。',
instancePolicy: 'multiple',
defaultContext: 'current-project',
defaultOpen: false,
required: false,
closable: true,
reorderable: true,
availability: { state: 'available' }
}
] as const satisfies readonly WorkbarAppDefinition[]
export const workbarTargetRefSchema = z.discriminatedUnion('type', [
export const workbarExecutionTargetRefSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('local') }).strict(),
z
.object({
@@ -152,6 +177,19 @@ export const workbarTargetRefSchema = z.discriminatedUnion('type', [
})
.strict()
])
export type WorkbarExecutionTargetRef = z.infer<
typeof workbarExecutionTargetRefSchema
>
export const workbarTargetRefSchema = z.union([
workbarExecutionTargetRefSchema,
z
.object({
type: z.literal('conversation'),
conversationId: z.string().min(1).max(128)
})
.strict()
])
export type WorkbarTargetRef = z.infer<
typeof workbarTargetRefSchema
>
@@ -165,13 +203,37 @@ export const workbarTabInstanceSchema = z
})
.strict()
.superRefine((instance, context) => {
if (instance.appId === 'terminal' && !instance.targetRef) {
if (
instance.appId === 'terminal' &&
(!instance.targetRef || instance.targetRef.type === 'conversation')
) {
context.addIssue({
code: 'custom',
path: ['targetRef'],
message: 'Terminal instances require a public target reference'
})
}
if (
instance.appId === 'browser' &&
instance.targetRef &&
instance.targetRef.type !== 'conversation'
) {
context.addIssue({
code: 'custom',
path: ['targetRef'],
message: 'Browser instances only accept conversation bindings'
})
}
if (
instance.appId !== 'browser' &&
instance.targetRef?.type === 'conversation'
) {
context.addIssue({
code: 'custom',
path: ['targetRef'],
message: 'Conversation bindings are reserved for browser instances'
})
}
})
export type WorkbarTabInstance = z.infer<
typeof workbarTabInstanceSchema
@@ -183,7 +245,14 @@ const singleInstanceAppIds = new Set<WorkbarAppId>(
).map((definition) => definition.id)
)
export const workbarLayoutPreferencesSchema = z
export const workbarTaskScopeSchema = z.enum([
'current-project',
'global',
'all-projects'
])
export type WorkbarTaskScope = z.infer<typeof workbarTaskScopeSchema>
const workbarLayoutShapeSchema = z
.object({
instances: z
.array(workbarTabInstanceSchema)
@@ -191,9 +260,12 @@ export const workbarLayoutPreferencesSchema = z
activeInstanceId: z.string().uuid().nullable(),
expanded: z.boolean(),
dock: z.literal('right'),
widthRatio: z.number().finite().gt(0).lt(1)
widthRatio: z.number().finite().gt(0).lt(1),
taskScope: workbarTaskScopeSchema.default('current-project')
})
.strict()
export const workbarLayoutPreferencesSchema = workbarLayoutShapeSchema
.superRefine((layout, context) => {
const instanceIds = new Set<string>()
const seenSingleApps = new Set<WorkbarAppId>()
@@ -244,3 +316,77 @@ export const workbarLayoutPreferencesSchema = z
export type WorkbarLayoutPreferences = z.infer<
typeof workbarLayoutPreferencesSchema
>
export function normalizeWorkbarLayoutPreferences(
value: unknown,
defaultInstances: readonly WorkbarTabInstance[]
): WorkbarLayoutPreferences | undefined {
const parsed = workbarLayoutShapeSchema.safeParse(value)
if (!parsed.success) {
return undefined
}
const instanceIds = new Set<string>()
const seenSingleApps = new Set<WorkbarAppId>()
const instances: WorkbarTabInstance[] = []
for (const instance of parsed.data.instances) {
if (
instanceIds.has(instance.id) ||
(singleInstanceAppIds.has(instance.appId) &&
seenSingleApps.has(instance.appId))
) {
continue
}
instanceIds.add(instance.id)
if (singleInstanceAppIds.has(instance.appId)) {
seenSingleApps.add(instance.appId)
}
instances.push(instance)
}
for (const defaultInstance of defaultInstances) {
const definition = WORKBAR_APP_DEFINITIONS.find(
(candidate) => candidate.id === defaultInstance.appId
)
if (
definition?.required !== true ||
instances.some(
(instance) => instance.appId === defaultInstance.appId
)
) {
continue
}
const collidingIdIndex = instances.findIndex(
(instance) => instance.id === defaultInstance.id
)
if (collidingIdIndex >= 0) {
const [removed] = instances.splice(collidingIdIndex, 1)
if (removed && singleInstanceAppIds.has(removed.appId)) {
seenSingleApps.delete(removed.appId)
}
}
instances.splice(
WORKBAR_APP_DEFINITIONS.findIndex(
(candidate) => candidate.id === defaultInstance.appId
),
0,
defaultInstance
)
}
const boundedInstances = instances.slice(
0,
WORKBAR_LIMITS.maximumOpenInstances
)
const activeInstanceId = boundedInstances.some(
(instance) => instance.id === parsed.data.activeInstanceId
)
? parsed.data.activeInstanceId
: boundedInstances[0]?.id ?? null
return workbarLayoutPreferencesSchema.parse({
...parsed.data,
instances: boundedInstances,
activeInstanceId
})
}