fix: complete runtime and workspace workflows

Restore Ask workspace reads and output paging without granting write access,
and let programming Subagents reuse their parent's browser page. Allocate
browser resources only when used, keep Agent pages aligned with workbar tabs,
recover released bindings, and remove unused automatic screenshot work.

Preserve inherited Runtime selections during remote usage persistence and
manual compression. Keep viable channel model fallbacks, display the actual
scheduled-task mode and Harness model source, and avoid rediscovery delays
when calling healthy MCP tools. Fix directory/history refresh, case-only
renames, rename-aware commit diffs, and FastGPT probe success reporting.

Add regression coverage and align feature documentation. The Agent-side
connection timeout fix requires deploying an updated Agent; existing Ask
read-only and Execute authorization boundaries remain unchanged.

Final validation: 3839 tests passed, 66 skipped; typecheck, lint, development
build, and diff checks passed. Isolated Electron UI/MCP workflows passed with
a loopback model fixture. Current-source Linux x64 Host checks passed with
three successful real model requests. Chromium loadURL rejection was not
reproduced in the UI; released-binding recovery has automated coverage.

Release note: 修复 Ask 读取项目文件、浏览器页面显示与恢复、文件管理及模型配置跟随中的不一致,并减少无用截图和 MCP 等待。定时任务显示实际工作模式,远程会话可正常保存继承 Runtime 的用量;远端连接超时修复需更新 Agent 后生效。
This commit is contained in:
mesalogo
2026-09-10 12:48:30 +08:00
parent 020a9ad0dc
commit c037d7fb20
62 changed files with 2637 additions and 521 deletions
+3 -1
View File
@@ -414,7 +414,9 @@ export function createUnixModelBridgeExchange(options: {
try {
await waitForConnection(
socket,
Math.min(connectTimeoutMs, requestTimeoutMs),
requestTimeoutMs === 0
? connectTimeoutMs
: Math.min(connectTimeoutMs, requestTimeoutMs),
context.signal
)
await writePacket(socket, {
+44 -2
View File
@@ -12,6 +12,8 @@ import {
type RequestOptions
} from 'node:http'
import { tmpdir } from 'node:os'
import net from 'node:net'
import { Duplex } from 'node:stream'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
@@ -612,6 +614,47 @@ describe('model bridge loopback helper', () => {
describe('model bridge Unix broker', () => {
const runOnUnix = process.platform === 'win32' ? it.skip : it
it('allows a delayed connection with the default unlimited request timeout', async () => {
const socket = new Duplex({
read() {},
write(chunk: Buffer, _encoding, callback) {
const packet = JSON.parse(chunk.subarray(4).toString()) as {
type: string
requestId: string
}
const response = Buffer.from(JSON.stringify(
packet.type === 'model-request'
? { version: 1, type: 'model-response', requestId: packet.requestId, ok: true, response: validResponse }
: { version: 1, type: 'delivery-accepted', requestId: packet.requestId }
))
const frame = Buffer.alloc(4 + response.length)
frame.writeUInt32BE(response.length)
response.copy(frame, 4)
this.push(frame)
callback()
}
})
vi.spyOn(net.Socket.prototype, 'connect').mockReturnValueOnce(socket as net.Socket)
const connect = setTimeout(() => socket.emit('connect'), 30)
try {
const exchange = createUnixModelBridgeExchange({
socketPath: resolve(tmpdir(), 'delayed-model-bridge.sock'),
connectTimeoutMs: 500
})
const result = await exchange({
method: 'POST', path: '/v1/messages', headers: {}, bodyBase64: ''
}, { requestId: 'delayed-connect', signal: new AbortController().signal })
expect('response' in result).toBe(true)
if ('response' in result) {
expect(result.response).toEqual(validResponse)
await result.acknowledgeDelivery()
}
} finally {
clearTimeout(connect)
socket.destroy()
}
})
runOnUnix('rejects a socket path beyond the Unix kernel limit', () => {
const scratch = resolve(
privateTemporaryDirectory(),
@@ -643,8 +686,7 @@ describe('model bridge Unix broker', () => {
expect(lstatSync(socketPath).mode & 0o777).toBe(0o600)
const exchange = createUnixModelBridgeExchange({
socketPath,
requestTimeoutMs: 1_000
socketPath
})
const request: RemoteModelGatewayRequest = {
method: 'POST',
@@ -1116,6 +1116,7 @@ describe('DeepSeekHarnessRuntime', () => {
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' })
await expect(
harness.extension('goodbuddy/tools/call', {
@@ -1145,6 +1146,8 @@ describe('DeepSeekHarnessRuntime', () => {
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' })
vi.mocked(provider.listTools).mockRejectedValue(new Error('unrelated server unavailable'))
await expect(
harness.extension('goodbuddy/tools/call', {
@@ -1158,6 +1161,7 @@ describe('DeepSeekHarnessRuntime', () => {
]
})
expect(authorize).toHaveBeenCalledTimes(1)
expect(provider.listTools).toHaveBeenCalledOnce()
expect(provider.callTool).toHaveBeenCalledWith(
mcpTool().name,
{ kind: 'cube' },
@@ -1283,6 +1287,7 @@ describe('DeepSeekHarnessRuntime', () => {
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' })
await expect(
harness.extension('goodbuddy/tools/call', {
@@ -1311,6 +1316,7 @@ describe('DeepSeekHarnessRuntime', () => {
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' })
await expect(
harness.extension('goodbuddy/tools/call', {
+10 -11
View File
@@ -16,7 +16,7 @@ import type {
RuntimeAuthorizer,
RuntimeEvent
} from './runtime'
import type { ModelToolProviderLike } from './model-tool-provider'
import type { ModelToolDefinition, ModelToolProviderLike } from './model-tool-provider'
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
import type { ControlledHarnessExtensionPackage } from './deepseek-harness-extension-loader'
import {
@@ -449,6 +449,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
private fatalError?: Error
private stderrBytes = 0
private readonly sessions = new Map<string, string>()
private readonly proxyToolCatalogs = new Map<string, ModelToolDefinition[]>()
private readonly sessionInitializations = new Map<
string,
Promise<string>
@@ -844,12 +845,11 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
context,
connection.signal
)
return {
tools: boundedProxyToolCatalog(
tools,
context.workMode
)
}
const catalog = boundedProxyToolCatalog(tools, context.workMode)
this.proxyToolCatalogs.set(params.sessionId, tools.filter(
(tool) => tool.source === 'mcp' || isMainWebTool(tool)
))
return { tools: catalog }
}
if (method === GOODBUDDY_TOOLS_CALL) {
const sessionId = params.sessionId
@@ -882,10 +882,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
knowledgeCapabilityToken:
run.request.knowledgeCapabilityToken
}
const tools = await this.options.toolProvider.listTools(
context,
run.toolController.signal
)
const tools = this.proxyToolCatalogs.get(sessionId as string) ?? []
const tool = tools.find(
(candidate) =>
candidate.name === name &&
@@ -1599,6 +1596,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
async releaseConversation(conversationId: string): Promise<void> {
const sessionId = this.sessions.get(conversationId)
this.sessions.delete(conversationId)
if (sessionId) this.proxyToolCatalogs.delete(sessionId)
if (!sessionId || !this.state) {
await this.options.toolProvider
?.releaseConversation(conversationId)
@@ -1646,6 +1644,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
}
this.activeRuns.clear()
this.sessions.clear()
this.proxyToolCatalogs.clear()
this.sessionInitializations.clear()
this.conversationTails.clear()
if (!state) {
@@ -23,6 +23,7 @@ import {
type MagicNotesDatabase
} from './knowledge-mcp-gateway'
import { browserTabIdSchema } from '../../shared/contracts'
import { BrowserService } from '../browser/browser-service'
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
@@ -231,6 +232,81 @@ afterEach(async () => {
})
describe('KnowledgeMcpGateway', () => {
it('grants reserved parent and inherited child targets without materializing a browser', async () => {
const { service } = createService()
const createSession = vi.fn(async (): Promise<never> => {
throw new Error('unused requests must not create a browser')
})
const browserService = new BrowserService({ createSession })
const gateway = new KnowledgeMcpGateway(service, { browserService })
gateways.push(gateway)
const parentController = new AbortController()
const parent = browserService.reserveRequestTab('conversation', 'parent', 21)
const child = browserService.acquireTabUsage('conversation', parent.tabId, 'child', 21)
try {
expect(browserService.listTabs('conversation')).toEqual([])
expect(() => gateway.grant('unleased', [], parentController.signal, 'none', undefined, 'conversation', parent.tabId))
.toThrow('不属于当前对话')
const parentToken = gateway.grant('parent', [], parentController.signal, 'none', undefined, 'conversation', parent.tabId, parent)!
const childToken = gateway.grant('child', [], new AbortController().signal, 'none', undefined, 'conversation', child.tabId, child)!
expect(gateway.getAvailableToolNames(parentToken)).toEqual(browserToolNames)
expect(gateway.getAvailableToolNames(childToken)).toEqual(browserToolNames)
expect(createSession).not.toHaveBeenCalled()
parentController.abort()
expect(parent.signal.aborted).toBe(true)
expect(child.signal.aborted).toBe(false)
expect(browserService.getOwnerWindowId('conversation')).toBe(21)
await gateway.start()
const navigate = vi.spyOn(browserService, 'navigate').mockResolvedValue({
url: 'https://example.com/',
origin: 'https://example.com'
})
const client = new Client({ name: 'reserved-child-test', version: '1.0.0' })
try {
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), {
requestInit: { headers: { Authorization: `Bearer ${childToken}` } }
}))
await client.callTool({ name: 'browser_navigate', arguments: { url: 'https://example.com/' } })
expect(navigate).toHaveBeenCalledWith('conversation', 'https://example.com/', expect.any(AbortSignal), parent.tabId)
expect(createSession).not.toHaveBeenCalled()
} finally {
await client.close()
}
gateway.revoke(childToken)
expect(child.signal.aborted).toBe(true)
expect(browserService.getOwnerWindowId('conversation')).toBeUndefined()
} finally {
await gateway.dispose()
await browserService.dispose()
}
})
it('rejects mismatched or ended reservation leases and releases pre-canceled grants', async () => {
const { service } = createService()
const browserService = new BrowserService()
const gateway = new KnowledgeMcpGateway(service, { browserService })
gateways.push(gateway)
try {
const mismatch = browserService.reserveRequestTab('conversation', 'owner', 21)
expect(() => gateway.grant('other-owner', [], new AbortController().signal, 'none', undefined, 'conversation', mismatch.tabId, mismatch))
.toThrow('与当前请求不匹配')
expect(mismatch.signal.aborted).toBe(true)
const released = browserService.reserveRequestTab('conversation', 'released', 21)
released.release()
expect(() => gateway.grant('released', [], new AbortController().signal, 'none', undefined, 'conversation', released.tabId, released))
.toThrow('租约已终止')
const canceled = browserService.reserveRequestTab('conversation', 'canceled', 21)
expect(() => gateway.grant('canceled', [], AbortSignal.abort(new Error('request canceled')), 'none', undefined, 'conversation', canceled.tabId, canceled))
.toThrow('request canceled')
expect(canceled.signal.aborted).toBe(true)
expect(browserService.getOwnerWindowId('conversation')).toBeUndefined()
expect(browserService.getSessionCount()).toBe(0)
} finally {
await browserService.dispose()
}
})
it('exposes the assigned browser through a request-scoped conversation', async () => {
const { service } = createService()
const browserTabId = browserTabIdSchema.parse(
+4 -3
View File
@@ -472,9 +472,9 @@ export class KnowledgeMcpGateway {
if (
effectiveBrowserConversationId &&
(!effectiveBrowserTabId ||
!this.browserService!
(!browserUsageLease && !this.browserService!
.listTabs(effectiveBrowserConversationId)
.some((tab) => tab.tabId === effectiveBrowserTabId))
.some((tab) => tab.tabId === effectiveBrowserTabId)))
) {
throw new Error('浏览器标签页不存在或不属于当前对话')
}
@@ -507,8 +507,9 @@ export class KnowledgeMcpGateway {
) {
return undefined
}
signal.throwIfAborted()
try {
signal.throwIfAborted()
effectiveBrowserUsageLease?.signal.throwIfAborted()
return this.storeCapability({
requestId,
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
+235 -29
View File
@@ -2,8 +2,10 @@ import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { rgPath } from '@vscode/ripgrep'
import { describe, expect, it, vi } from 'vitest'
import {
ModelToolProvider,
RecoverableModelToolError,
type ModelToolDefinition,
type ModelToolProviderLike,
@@ -12,6 +14,10 @@ import {
import { ModelAgentRuntime } from './model-runtime'
import type { RuntimeEvent } from './runtime'
import { SubagentScheduler } from '../assistant/subagent-scheduler'
import { LocalWorkspaceAccess } from '../workspace'
import { LocalDirectModelProcessService } from './direct-model-process-service'
import { browserTabIdSchema } from '../../shared/contracts'
import type { BrowserToolService } from '../browser/browser-model-tools'
const toolPng = Buffer.from([
0x89, 0x50, 0x4e, 0x47,
@@ -2193,39 +2199,151 @@ describe('ModelAgentRuntime', () => {
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('keeps browser and workspace tools out of Ask mode', async () => {
const fetcher = vi.fn<typeof fetch>(async () =>
new Response('data: {"choices":[{"delta":{"content":"只读回答"}}]}\n\ndata: [DONE]\n\n', {
status: 200,
headers: { 'content-type': 'text/event-stream' }
})
it('runs Ask workspace search, text reads and output paging with the production deny authorizer and no optional capabilities', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-ask-tools-'))
const workspaceAccess = new LocalWorkspaceAccess(workspace)
const processService = new LocalDirectModelProcessService()
const toolProvider = new ModelToolProvider(
workspaceAccess, [], undefined, undefined, false,
{ processService, ripgrepExecutablePath: rgPath }
)
const signal = new AbortController().signal
const conversationId = 'conversation-ask'
const responses: unknown[] = []
const fetcher = vi.fn<typeof fetch>(async () => Response.json(responses.shift()))
const runtime = new ModelAgentRuntime({
baseUrl: 'https://example.test/v1',
model: 'test-model',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider,
workspaceAccess
})
const authorize = vi.fn(async () => 'deny' as const)
try {
await writeFile(join(workspace, 'README.md'), 'readonly-marker\n')
await writeFile(join(workspace, 'output.cjs'),
"process.stdout.write('x'.repeat(110000) + 'retained-tail')")
// Seed output as a preceding Execute operation in the same conversation.
const previous = await processService.execute(
{ command: 'node output.cjs', timeoutMs: 10_000 },
{ conversationId, workspace: workspaceAccess, signal }
)
const toolProvider = createToolProvider()
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider
expect(previous.exitCode).toBe(0)
const reference = previous.stdoutReference!
expect(reference).toBeDefined()
const calls = [
{ name: 'workspace_rg', arguments: { pattern: 'readonly-marker', glob: ['README.md'] } },
{ name: 'workspace_read_text', arguments: { path: 'README.md' } },
{ name: 'output_read', arguments: { handle: reference.handle, cursor: reference.nextCursor } }
]
responses.push({
choices: [{ message: {
role: 'assistant', content: null,
tool_calls: calls.map((call, index) => ({
id: `ask-${index}`, type: 'function',
function: { name: call.name, arguments: JSON.stringify(call.arguments) }
}))
} }]
}, { choices: [{ message: { role: 'assistant', content: '只读完成' } }] })
const events: RuntimeEvent[] = []
for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId,
prompt: '搜索并读取文件,续读已有输出', workMode: 'ask'
}, signal, authorize)) events.push(event)
const firstBody = JSON.parse(String(fetcher.mock.calls[0]![1]!.body))
expect(firstBody.tools.map((tool: { function: { name: string } }) =>
tool.function.name)).toEqual(['workspace_rg', 'workspace_read_text', 'output_read'])
const secondBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body))
const results = secondBody.messages.filter(
(message: { role: string }) => message.role === 'tool'
)
expect(results).toHaveLength(3)
expect(results[0].content).toContain('README.md:1:1:readonly-marker')
expect(results[1].content).toContain('readonly-marker')
expect(JSON.parse(results[2].content)).toMatchObject({
content: expect.stringContaining('retained-tail'), eof: true
})
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: 'conversation-ask',
prompt: '只读',
workMode: 'ask'
},
new AbortController().signal
)) {
void _event
}
expect(toolProvider.listTools).not.toHaveBeenCalled()
expect(toolProvider.callTool).not.toHaveBeenCalled()
expect(authorize).not.toHaveBeenCalled()
expect(events.filter((event) =>
event.type === 'tool' && event.state === 'completed')).toHaveLength(3)
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(await readFile(join(workspace, 'README.md'), 'utf8')).toBe('readonly-marker\n')
} finally {
await runtime.dispose()
await rm(workspace, { recursive: true, force: true })
}
})
it.each(['workspace_apply_patch', 'workspace_write_text', 'process_execute', 'browser_snapshot'])(
'rejects unlisted %s in Ask at the runtime boundary',
async (name) => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-ask-denial-'))
const fetcher = vi.fn<typeof fetch>(async () => Response.json({
choices: [{ message: {
role: 'assistant', content: null,
tool_calls: [{ id: 'spoof', type: 'function', function: {
name, arguments: JSON.stringify({
path: 'forbidden.txt', content: 'forbidden',
patch: '*** Begin Patch\n*** Add File: forbidden.txt\n+forbidden\n*** End Patch',
command: 'echo forbidden'
})
} }]
} }]
}))
const runtime = new ModelAgentRuntime({
baseUrl: 'https://example.test/v1', model: 'test-model',
protocol: 'openai-chat-completions', authentication: 'none',
fetcher, defaultWorkspace: workspace
})
const authorize = vi.fn(async () => 'deny' as const)
try {
const consume = async () => {
for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId: 'ask-denied',
prompt: '只读', workMode: 'ask'
}, new AbortController().signal, authorize)) void event
}
await expect(consume()).rejects.toThrow('未知工具')
expect(authorize).not.toHaveBeenCalled()
await expect(readFile(join(workspace, 'forbidden.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
} finally {
await runtime.dispose()
await rm(workspace, { recursive: true, force: true })
}
}
)
it.each(['openai-chat-completions', 'openai-responses', 'anthropic-messages'] as const)(
'lists only workspace reads and retained output in plain Ask via %s',
async (protocol) => {
const fetcher = vi.fn<typeof fetch>(async () => Response.json(
protocol === 'anthropic-messages'
? { content: [{ type: 'text', text: '只读回答' }], stop_reason: 'end_turn' }
: protocol === 'openai-responses'
? { id: 'resp-ask', output: [{ type: 'message', content: [{ type: 'output_text', text: '只读回答' }] }] }
: { choices: [{ message: { role: 'assistant', content: '只读回答' } }] }
))
const runtime = new ModelAgentRuntime({
baseUrl: 'https://example.test/v1', model: 'test-model',
protocol, authentication: 'none', fetcher
})
try {
for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId: 'plain-ask',
prompt: '只读', workMode: 'ask'
}, new AbortController().signal, async () => 'deny')) void event
const body = JSON.parse(String(fetcher.mock.calls[0]![1]!.body))
expect(body.tools.map((tool: { name?: string; function?: { name: string } }) =>
tool.function?.name ?? tool.name)).toEqual(['workspace_read_text', 'output_read'])
} finally {
await runtime.dispose()
}
}
)
it('uses the OpenAI Responses endpoint and streams output text', async () => {
const fetcher = vi.fn<typeof fetch>(async () =>
new Response(
@@ -2603,6 +2721,94 @@ describe('ModelAgentRuntime', () => {
}
)
it('lets an Execute Subagent borrow the parent browser tab without changing tab ownership', async () => {
const conversationId = 'parent-browser-owner'
const browserTabId = browserTabIdSchema.parse('00000000-0000-4000-8000-000000000203')
let parentReleased = false
const browserService: BrowserToolService = {
getOrigin: vi.fn(() => 'https://example.test'),
navigate: vi.fn(), click: vi.fn(), type: vi.fn(),
select: vi.fn(), back: vi.fn(), screenshot: vi.fn(),
snapshot: vi.fn(async (owner, _signal, tabId) => {
// BrowserService resolves tabs under their owning conversation, not
// merely by tab ID; a child's temporary conversation cannot own it.
expect(owner).toBe(conversationId)
expect(tabId).toBe(browserTabId)
expect(parentReleased).toBe(false)
return { url: 'https://example.test/', title: 'Parent page', nodes: [], truncated: false }
}),
releaseConversation: vi.fn(async (owner) => {
if (owner === conversationId) parentReleased = true
})
}
const toolResponse = (id: string, name: string, args: Record<string, unknown>) => ({
choices: [{ message: {
role: 'assistant', content: null,
tool_calls: [{ id, type: 'function', function: {
name, arguments: JSON.stringify(args)
} }]
} }]
})
const responses = [
toolResponse('delegate', 'subagent_delegate', { task: '读取父请求浏览器页面' }),
toolResponse('child-browser', 'browser_snapshot', {}),
{ choices: [{ message: { role: 'assistant', content: '子级已读页面' } }] },
toolResponse('parent-browser', 'browser_snapshot', {}),
{ choices: [{ message: { role: 'assistant', content: '父级继续使用同一页面' } }] }
]
const fetcher = vi.fn<typeof fetch>(async () => Response.json(responses.shift()))
const scheduler = new SubagentScheduler({ concurrency: 1, queueLimit: 1, timeoutMs: 10_000 })
const runtime = new ModelAgentRuntime({
baseUrl: 'https://example.test/v1', model: 'test-model',
protocol: 'openai-chat-completions', authentication: 'none',
fetcher, browserService, directModelSubagentScheduler: scheduler
})
const listTools = vi.spyOn(ModelToolProvider.prototype, 'listTools')
const callTool = vi.spyOn(ModelToolProvider.prototype, 'callTool')
try {
const events: RuntimeEvent[] = []
for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId, browserTabId,
prompt: '委派读取页面后继续使用页面', workMode: 'execute'
}, new AbortController().signal, async () => 'once')) events.push(event)
expect(fetcher).toHaveBeenCalledTimes(5)
const childContext = listTools.mock.calls.find(
([context]) => context.delegationDepth === 1
)![0]
expect(childContext).toMatchObject({
workMode: 'execute', browserTabId,
browserConversationId: conversationId
})
expect(childContext.conversationId).not.toBe(conversationId)
const childBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body))
const childTools = childBody.tools.map(
(tool: { function: { name: string } }) => tool.function.name
)
expect(childTools).toEqual(expect.arrayContaining(['browser_navigate', 'browser_snapshot', 'browser_click']))
expect(childTools).not.toContain('subagent_delegate')
expect(callTool).toHaveBeenCalledWith(
'browser_snapshot', {}, expect.any(AbortSignal),
expect.objectContaining({
conversationId: childContext.conversationId,
browserConversationId: conversationId, browserTabId
})
)
expect(browserService.snapshot).toHaveBeenCalledTimes(2)
expect(browserService.releaseConversation).toHaveBeenCalledWith(childContext.conversationId)
expect(parentReleased).toBe(false)
expect(events.filter((event) => event.type === 'subagent').at(-1))
.toMatchObject({ state: 'completed' })
await runtime.releaseConversation(conversationId)
expect(parentReleased).toBe(true)
} finally {
listTools.mockRestore()
callTool.mockRestore()
await runtime.dispose()
scheduler.dispose()
await scheduler.waitForIdle()
}
})
it('delegates one programming Subagent that can run a real project command without recursion', async () => {
const command =
process.platform === 'win32'
+27 -6
View File
@@ -86,6 +86,12 @@ type ConversationMessage = {
content: string
}
// Temporary Subagents retain separate history/output ownership while borrowing
// the parent's browser tab and its owning conversation. They acquire no lease.
type ModelExecutionRequest = AgentExecutionRequest & {
browserConversationId?: string
}
type ProviderConversationMessage = Pick<
ConversationMessage,
'role' | 'content'
@@ -120,6 +126,11 @@ type AgentRunCompressionState = {
}
const scopedReadToolNameSet = new Set<string>(scopedReadToolNames)
const askWorkspaceReadToolNames = new Set([
'workspace_rg',
'workspace_read_text',
'output_read'
])
type AnthropicApiMessage = {
role: 'user' | 'assistant'
@@ -1678,6 +1689,9 @@ export class ModelAgentRuntime implements AgentRuntime {
{
requestId: input.context.childRunId,
conversationId: input.context.conversationId,
browserTabId: input.requestContext.browserTabId,
browserConversationId:
input.requestContext.browserConversationId,
projectId: input.context.projectId,
workMode: input.context.workMode,
prompt: input.task,
@@ -3331,7 +3345,7 @@ export class ModelAgentRuntime implements AgentRuntime {
}
private async *runToolExecution(
request: AgentExecutionRequest,
request: ModelExecutionRequest,
signal: AbortSignal,
authorize: RuntimeAuthorizer | undefined,
system: string,
@@ -3364,6 +3378,7 @@ export class ModelAgentRuntime implements AgentRuntime {
const toolContext: ModelToolCallContext = {
conversationId: request.conversationId,
browserTabId: request.browserTabId,
browserConversationId: request.browserConversationId,
workMode: request.workMode ?? 'ask',
requestId: request.requestId,
runtimeTarget: 'model',
@@ -3376,6 +3391,9 @@ export class ModelAgentRuntime implements AgentRuntime {
subagentBridge: {
requestContext: {
authorize,
browserTabId: request.browserTabId,
browserConversationId:
request.browserConversationId ?? request.conversationId,
knowledgeCapabilityToken:
request.knowledgeCapabilityToken,
emitEvent: emitNestedEvent
@@ -3730,6 +3748,8 @@ export class ModelAgentRuntime implements AgentRuntime {
try {
if (
tool.name === 'subagent_delegate' ||
(toolContext.workMode === 'ask' &&
askWorkspaceReadToolNames.has(tool.name)) ||
(scopedReadToolNameSet.has(tool.name) &&
Boolean(request.knowledgeCapabilityToken)) ||
tool.name === 'web_search' ||
@@ -3894,7 +3914,7 @@ export class ModelAgentRuntime implements AgentRuntime {
}
async *run(
request: AgentExecutionRequest,
request: ModelExecutionRequest,
signal: AbortSignal,
authorize?: RuntimeAuthorizer
): AsyncGenerator<RuntimeEvent, void, void> {
@@ -3952,7 +3972,10 @@ export class ModelAgentRuntime implements AgentRuntime {
}
yield result.value
}
const executionRequest = prepared.request
const executionRequest: ModelExecutionRequest = {
...prepared.request,
browserConversationId: request.browserConversationId
}
yield {
requestId: request.requestId,
@@ -3971,9 +3994,7 @@ export class ModelAgentRuntime implements AgentRuntime {
if (
executionRequest.workMode === 'execute' ||
(executionRequest.workMode === 'ask' &&
(Boolean(executionRequest.knowledgeCapabilityToken) ||
this.options.webSearchEnabled === true ||
this.directModelSubagentEnabled))
this.toolProvider !== noModelTools)
) {
yield* this.runToolExecution(
executionRequest,
+51 -2
View File
@@ -235,6 +235,19 @@ describe('ModelToolProvider', () => {
expect.stringContaining('Search MCP'), expect.any(Error)
)
const healthyName = tools.find((tool) => tool.serverName === 'Healthy')!.name
for (let index = 0; index < 2; index += 1) {
await provider.callTool(healthyName, {}, signal, toolContext)
}
await expect(provider.callTool('unknown_mcp', {}, signal, toolContext))
.rejects.toThrow('未知工具')
// No 30-second failed-server reconnect may precede a known healthy call.
expect(mocks.Client).toHaveBeenCalledTimes(2)
expect(mocks.client.connect).toHaveBeenCalledOnce()
expect(mocks.client.listTools).toHaveBeenCalledOnce()
expect(failedClient[failureMethod]).toHaveBeenCalledOnce()
expect(mocks.client.callTool).toHaveBeenCalledTimes(2)
const recovered = await provider.listTools(toolContext, signal)
expect(recovered.filter((tool) => tool.source === 'mcp')).toHaveLength(2)
expect(mocks.Client).toHaveBeenCalledTimes(3)
@@ -243,7 +256,7 @@ describe('ModelToolProvider', () => {
tools.find((tool) => tool.serverName === 'Healthy')!.name,
{}, signal, toolContext
)
expect(mocks.client.callTool).toHaveBeenCalledOnce()
expect(mocks.client.callTool).toHaveBeenCalledTimes(3)
} finally {
await provider.dispose()
warning.mockRestore()
@@ -251,6 +264,33 @@ describe('ModelToolProvider', () => {
}
)
it('clears discovered bindings on disposal and does not carry them to a replacement configuration', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockResolvedValue({
tools: [{ name: 'search', inputSchema: { type: 'object' } }]
})
const provider = new ModelToolProvider(workspace, [createMcpServer()])
const replacement = new ModelToolProvider(workspace, [])
const signal = new AbortController().signal
try {
const tools = await provider.listTools(toolContext, signal)
const name = tools.find((tool) => tool.source === 'mcp')!.name
await provider.callTool(name, {}, signal, toolContext)
await provider.dispose()
await expect(provider.callTool(name, {}, signal, toolContext))
.rejects.toThrow('未知工具')
expect((await replacement.listTools(toolContext, signal))
.filter((tool) => tool.source === 'mcp')).toEqual([])
await expect(replacement.callTool(name, {}, signal, toolContext))
.rejects.toThrow('未知工具')
expect(mocks.client.callTool).toHaveBeenCalledOnce()
expect(mocks.Client).toHaveBeenCalledOnce()
} finally {
await provider.dispose()
await replacement.dispose()
}
})
it('loads tools from more than sixteen MCP servers', async () => {
mocks.client.listTools.mockResolvedValue({
tools: [{ name: 'search', inputSchema: { type: 'object' } }]
@@ -302,7 +342,7 @@ describe('ModelToolProvider', () => {
])
const signal = new AbortController().signal
try {
await provider.listTools(toolContext, signal)
const originalTools = await provider.listTools(toolContext, signal)
const options = mocks.Client.mock.calls[0]![1] as {
listChanged: { tools: { onChanged(error: Error | null): void } }
}
@@ -312,6 +352,15 @@ describe('ModelToolProvider', () => {
expect(tools.filter((tool) => tool.source === 'mcp')).toEqual([
expect.objectContaining({ serverName: 'Healthy' })
])
await expect(provider.callTool(
originalTools.find((tool) => tool.serverName === 'Search MCP')!.name,
{}, signal, toolContext
)).rejects.toThrow('未知工具')
await provider.callTool(
tools.find((tool) => tool.serverName === 'Healthy')!.name,
{}, signal, toolContext
)
expect(mocks.client.listTools).toHaveBeenCalledTimes(3)
expect(mocks.client.close).not.toHaveBeenCalled()
const recovered = await provider.listTools(toolContext, signal)
expect(recovered.filter((tool) => tool.source === 'mcp')).toHaveLength(2)
+10 -2
View File
@@ -291,6 +291,7 @@ export type ModelToolResult = {
export type ModelToolCallContext = {
conversationId: string
browserTabId?: BrowserTabId
browserConversationId?: string
workMode: 'ask' | 'execute'
requestId?: string
runtimeTarget?: 'model'
@@ -302,6 +303,8 @@ export type ModelToolCallContext = {
export type ModelSubagentRequestContext = {
authorize?: RuntimeAuthorizer
browserTabId?: BrowserTabId
browserConversationId?: string
knowledgeCapabilityToken?: string
emitEvent: (event: RuntimeEvent) => void
}
@@ -663,6 +666,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
Promise<ConnectedMcp | undefined>
>()
private webSearchBindings?: Promise<Map<string, McpToolBinding>>
private mcpBindings = new Map<string, McpToolBinding>()
private readonly clients = new Set<Client>()
private readonly customMcpClients = new Set<Client>()
private readonly webSearchClients = new Set<Client>()
@@ -739,7 +743,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
context.browserTabId
? new BrowserModelTools({
service: this.browserService,
conversationId: context.conversationId,
conversationId:
context.browserConversationId ?? context.conversationId,
browserTabId: context.browserTabId
})
: undefined
@@ -1211,6 +1216,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
bindings.set(binding.definition.name, binding)
}
}
this.mcpBindings = bindings
return bindings
}
@@ -1823,7 +1829,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
)
}
const binding = (await this.getMcpBindings(signal)).get(name)
// Calls use the discovered catalog; only listTools retries failed servers.
const binding = this.mcpBindings.get(name)
if (!binding) {
throw new Error('模型请求了未知工具')
}
@@ -1881,6 +1888,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
this.customMcpClients.clear()
this.webSearchClients.clear()
this.mcpConnections.clear()
this.mcpBindings.clear()
this.webSearchBindings = undefined
await Promise.allSettled([
...clients.map((client) => client.close()),
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { applyRuntimeSelection } from './runtime-selection'
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
import { defaultRuntimeCustomizationSettings } from '../../shared/contracts'
import {
repairChannelRuntimeSelection,
type AgentRuntimeSelection,
type RuntimeSelectionRepairSettings
} from '../../shared/runtime-selection-contracts'
const defaultId = '00000000-0000-4000-8000-000000000001'
const fallbackId = '00000000-0000-4000-8000-000000000002'
const missingId = '00000000-0000-4000-8000-000000000003'
function settings(
defaultProfile: Partial<RuntimeSelectionRepairSettings['modelProfiles'][number]>
): RuntimeSelectionRepairSettings {
return {
defaultModelProfileId: defaultId,
modelProfiles: [
{
id: defaultId, protocol: 'openai-chat-completions',
authentication: 'api-key', apiKeyConfigured: true, ...defaultProfile
},
{
id: fallbackId, protocol: 'openai-chat-completions',
authentication: 'none', apiKeyConfigured: false
}
],
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' }
}
}
function resolvedSettings(input: RuntimeSelectionRepairSettings): ResolvedRuntimeSettings {
return {
provider: 'model', modelBaseUrl: 'https://model.example/v1', modelName: 'default',
modelProtocol: 'openai-chat-completions', modelAuthentication: 'api-key',
imageGenerationQuality: 'auto',
modelProfiles: input.modelProfiles.map((profile) => ({
id: profile.id, name: profile.id, baseUrl: 'https://model.example/v1',
modelName: profile.id,
protocol: profile.protocol as ResolvedRuntimeSettings['modelProtocol'],
authentication: profile.authentication!,
apiKey: profile.apiKeyConfigured ? 'fixture-key' : undefined
})),
defaultModelProfileId: input.defaultModelProfileId,
opencodeBaseUrl: '', opencodeEmbedded: false, opencodeBinaryPath: '',
opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '',
continueMode: 'chat', subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false, knowledgeEmbeddingBaseUrl: '',
knowledgeEmbeddingModel: '', knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: '', knowledgeRerankModel: '',
runtimeCustomization: defaultRuntimeCustomizationSettings,
workspacePath: '.', toolApproval: 'always'
}
}
describe('channel direct model repair', () => {
const selections: AgentRuntimeSelection[] = [
{ provider: 'auto' },
{ provider: 'model' },
{ provider: 'model', profileId: defaultId },
{ provider: 'model', profileId: missingId }
]
it.each([
{ protocol: 'openai-images-generations' },
{ apiKeyConfigured: false },
{ id: missingId }
])('pins a verified fallback when the default is unusable: %j', (defaultProfile) => {
const input = settings(defaultProfile)
if (defaultProfile.id) input.modelProfiles = input.modelProfiles.slice(1)
for (const selection of selections) {
const repaired = repairChannelRuntimeSelection(selection, input)
expect(repaired).toEqual({ provider: 'model', profileId: fallbackId })
const resolved = applyRuntimeSelection(resolvedSettings(input), repaired)
expect(resolved.settings.defaultModelProfileId).toBe(fallbackId)
expect(resolved.settings.modelProtocol).toBe('openai-chat-completions')
expect(resolved.settings.modelAuthentication).toBe('none')
}
})
it('keeps a healthy default dynamic and preserves valid explicit pins', () => {
const input = settings({})
for (const selection of [selections[0]!, selections[1]!, selections[3]!]) {
const repaired = repairChannelRuntimeSelection(selection, input)
expect(repaired).toEqual({ provider: 'model' })
const changed = { ...resolvedSettings(input), defaultModelProfileId: fallbackId }
expect(applyRuntimeSelection(changed, repaired).settings.defaultModelProfileId).toBe(fallbackId)
}
for (const profileId of [defaultId, fallbackId]) {
const selection = { provider: 'model', profileId } as const
expect(repairChannelRuntimeSelection(selection, input)).toEqual(selection)
}
})
it('preserves a valid pin even when the global default is an image model', () => {
const input = settings({ protocol: 'openai-images-generations' })
const selection = { provider: 'model', profileId: fallbackId } as const
expect(repairChannelRuntimeSelection(selection, input)).toEqual(selection)
})
it('does not invent a profile when no usable fallback exists', () => {
const input = settings({ apiKeyConfigured: false })
input.modelProfiles = input.modelProfiles.slice(0, 1)
expect(repairChannelRuntimeSelection({ provider: 'auto' }, input))
.toEqual({ provider: 'model' })
})
})
@@ -10,6 +10,7 @@ import {
isUntouchedBuiltInDefaultProject
} from '../../shared/assistant-contracts'
import { AssistantDatabase } from './assistant-database'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
const temporaryDirectories: string[] = []
const channelDefaultProfileId =
@@ -3108,6 +3109,79 @@ describe('AssistantDatabase', () => {
inspected.close()
})
it('persists inherited remote metrics and replay without pinning the conversation', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-inherited-remote-metrics-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
let database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
const project = database.createSshProject(validatedSshProjectWrite())
const conversationId = '00000000-0000-4000-8000-000000000701'
const taskId = '00000000-0000-4000-8000-000000000702'
const assistantMessageId = '00000000-0000-4000-8000-000000000704'
database.saveLocalConversations([{
header: { id: conversationId, projectId: project.id, title: '继承', updatedAt: 0 },
messages: []
}])
database.createTask({
id: taskId, projectId: project.id, conversationId,
title: '继承', instructions: '回复', workMode: 'ask',
remoteRecovery: {
recoverable: true,
currentUserMessageId: '00000000-0000-4000-8000-000000000703',
currentAssistantMessageId: assistantMessageId
}
})
const provenance = {
taskId, conversationId, assistantMessageId,
bindingId: 'inherited-metrics', operationId: 'inherited-metrics', eventIndex: 0
}
const metrics = {
...provenance, semanticSequence: '1',
event: {
requestId: taskId, type: 'context-metrics' as const,
contextTokens: 123, source: 'provider' as const,
effectiveTriggerTokens: 1000, compressionEnabled: false
}
}
expect(database.appendRemoteConversationTaskEventOnce(metrics)).toBe(true)
expect(database.getConversation(conversationId)).toMatchObject({
contextMetrics: { runtimeSelectionKey: agentRuntimeSelectionKey({ provider: 'opencode' }), contextTokens: 123 }
})
database.close()
database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
expect(database.appendRemoteConversationTaskEventOnce(metrics)).toBe(false)
const runtimeSelection = { provider: 'opencode' as const, profileId: channelDefaultProfileId }
expect(database.appendRemoteConversationTaskEventOnce({
...metrics, semanticSequence: '2', runtimeSelection
})).toBe(true)
expect(database.getConversation(conversationId).contextMetrics?.runtimeSelectionKey)
.toBe(agentRuntimeSelectionKey(runtimeSelection))
expect(database.appendRemoteConversationTaskEventOnce({
...provenance, semanticSequence: '3', runtimeSelection,
event: {
requestId: taskId, type: 'context-compression', state: 'completed',
scope: 'conversation', estimatedBeforeTokens: 123, estimatedAfterTokens: 45,
effectiveTriggerTokens: 1000, recentRawTokens: 45
}
})).toBe(true)
expect(database.getConversation(conversationId)).toMatchObject({
contextMetrics: {
runtimeSelectionKey: agentRuntimeSelectionKey(runtimeSelection),
contextTokens: 45, source: 'estimated', basis: 'conversation'
}
})
expect(database.getConversation(conversationId).runtimeSelection).toBeUndefined()
database.close()
const raw = new DatabaseSync(databasePath)
expect(raw.prepare('SELECT runtime_selection_json FROM conversations WHERE id = ?').get(conversationId))
.toEqual({ runtime_selection_json: null })
raw.close()
})
it('creates and recovers a remote-authoritative conversation task', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-remote-conversation-recovery-')
+17 -14
View File
@@ -113,6 +113,7 @@ export type RemoteConversationTaskEventInput = Omit<
> & {
conversationId: string
assistantMessageId: string
runtimeSelection?: AgentRuntimeSelection
event:
| AgentEvent
| {
@@ -4940,6 +4941,10 @@ export class AssistantDatabase {
const assistantMessageId = assistantIdSchema.parse(
input.assistantMessageId
)
const requestRuntimeSelection =
input.runtimeSelection === undefined
? undefined
: agentRuntimeSelectionSchema.parse(input.runtimeSelection)
const publicEvent = stripRemoteEventProvenance(input.event)
if (publicEvent.requestId !== input.taskId) {
throw new Error('远程事件的请求 ID 与任务不匹配')
@@ -5054,13 +5059,16 @@ export class AssistantDatabase {
}
const conversation = database
.prepare(
`SELECT runtime_selection_json, context_state_json
FROM conversations
WHERE id = ?`
`SELECT c.runtime_selection_json, c.context_state_json,
p.runtime_selection_json AS project_runtime_selection_json
FROM conversations c
LEFT JOIN projects p ON p.id = c.project_id
WHERE c.id = ?`
)
.get(conversationId) as
| {
runtime_selection_json: string | null
project_runtime_selection_json: string | null
context_state_json: string | null
}
| undefined
@@ -5070,13 +5078,12 @@ export class AssistantDatabase {
let contextState = parseConversationContextState(
conversation.context_state_json
)
const runtimeSelection =
requestRuntimeSelection ??
parseRuntimeSelection(conversation.runtime_selection_json) ??
parseRuntimeSelection(conversation.project_runtime_selection_json) ??
{ provider: 'auto' as const }
if (publicEvent.type === 'context-metrics') {
const runtimeSelection = parseRuntimeSelection(
conversation.runtime_selection_json
)
if (!runtimeSelection) {
throw new Error('远程事件的对话缺少 Runtime 选择')
}
contextState = {
...contextState,
contextMetrics: {
@@ -5091,9 +5098,6 @@ export class AssistantDatabase {
publicEvent.type === 'context-compression' &&
publicEvent.scope !== 'agent-run'
) {
const runtimeSelection = parseRuntimeSelection(
conversation.runtime_selection_json
)
const estimatedAfterTokens =
publicEvent.estimatedAfterTokens
contextState = {
@@ -5105,8 +5109,7 @@ export class AssistantDatabase {
}
: {}),
...(publicEvent.state === 'completed' &&
estimatedAfterTokens !== undefined &&
runtimeSelection
estimatedAfterTokens !== undefined
? {
contextMetrics: {
runtimeSelectionKey:
+224 -156
View File
@@ -151,7 +151,6 @@ function createHarness(options: {
maximumTabsPerWindow: options.maximumTabsPerWindow,
idleTimeoutMs: options.idleTimeoutMs,
cleanupTimeoutMs: options.cleanupTimeoutMs,
liveFrameDelayMs: 0,
createSession,
createDriver: (contents) => {
const slot = byContents.get(contents)
@@ -173,6 +172,185 @@ describe('BrowserService', () => {
const secondLeaseToken = 'db8a2c28-43a6-4aab-93e0-1c01b9374dca'
const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251'
it('reserves four unused requests without resources, events, or consuming session capacity', async () => {
const harness = createHarness({ maximumSessions: 3 })
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const leases = ['one', 'two', 'three', 'four'].map((id) =>
harness.service.reserveRequestTab(id, `request-${id}`, 21)
)
expect(harness.service.getSessionCount()).toBe(0)
expect(harness.service.getTabCount()).toBe(0)
expect(harness.createSession).not.toHaveBeenCalled()
expect(states).toEqual([])
expect(harness.service.getOwnerWindowId('one')).toBe(21)
for (const lease of leases) {
lease.release()
lease.release()
expect(lease.signal.aborted).toBe(true)
expect(harness.service.getOwnerWindowId(lease.conversationId)).toBeUndefined()
}
const replacement = harness.service.reserveRequestTab('one', 'replacement', 22)
expect(replacement.tabId).not.toBe(leases[0]!.tabId)
await expect(harness.service.navigate('one', 'https://example.com/', new AbortController().signal, leases[0]!.tabId, 22))
.rejects.toThrow('不属于当前对话')
expect(harness.createSession).not.toHaveBeenCalled()
replacement.release()
await harness.service.dispose()
})
it('shares the reserved identity through navigation, workbar lookup, and all usage leases', async () => {
const harness = createHarness()
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const first = harness.service.reserveRequestTab('conversation', 'first', 21)
const second = harness.service.reserveRequestTab('conversation', 'second', 21)
expect(second.tabId).toBe(first.tabId)
expect(() => harness.service.reserveRequestTab('conversation', 'wrong-window', 22)).toThrow('不属于当前窗口')
const signal = new AbortController().signal
await Promise.all([
harness.service.navigate('conversation', 'https://example.com/first', signal, first.tabId),
harness.service.navigate('conversation', 'https://example.com/second', signal, second.tabId)
])
expect(harness.createSession).toHaveBeenCalledOnce()
expect(states[0]).toMatchObject({
status: 'creating',
tabId: first.tabId,
workbarInstanceId: first.tabId,
ownerWindowId: 21
})
expect(states.every((state) => state.workbarInstanceId === first.tabId)).toBe(true)
expect(harness.service.listTabs('conversation', 21)).toEqual([
expect.objectContaining({ tabId: first.tabId, workbarInstanceId: first.tabId })
])
const restored = await harness.service.createTab('conversation', 21, signal, first.tabId)
expect(restored.tabId).toBe(first.tabId)
expect(harness.service.getTabCount()).toBe(1)
first.release()
await expect(harness.service.closeTab('conversation', first.tabId, 21)).rejects.toThrow('正在被活动请求使用')
second.release()
await harness.service.closeTab('conversation', first.tabId, 21)
await harness.service.closeTab('conversation', first.tabId, 21)
await harness.service.dispose()
})
it('keeps a fresh explicit workbar separate from a reserved request target', async () => {
const harness = createHarness()
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const request = harness.service.reserveRequestTab('conversation', 'request', 21)
const signal = new AbortController().signal
const explicit = await harness.service.createTab('conversation', 21, signal, workbarInstanceId)
expect(explicit.tabId).not.toBe(request.tabId)
expect(explicit.workbarInstanceId).toBe(workbarInstanceId)
expect(harness.service.getTabCount()).toBe(1)
expect(states.every((state) => state.tabId === explicit.tabId)).toBe(true)
await harness.service.navigate('conversation', 'https://example.com/', signal, request.tabId, 21)
expect(harness.service.listTabs('conversation', 21)).toHaveLength(2)
expect(states.find((state) => state.tabId === request.tabId)).toMatchObject({
status: 'creating',
workbarInstanceId: request.tabId
})
expect(explicit.url).toBeUndefined()
request.release()
await harness.service.dispose()
})
it('publishes the explicit workbar identity from the first creating event', async () => {
const harness = createHarness()
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const tab = await harness.service.createTab('conversation', 21, new AbortController().signal, workbarInstanceId)
expect(states[0]).toMatchObject({ status: 'creating', tabId: tab.tabId, workbarInstanceId })
expect(states.every((state) => state.workbarInstanceId === workbarInstanceId)).toBe(true)
expect(tab.workbarInstanceId).toBe(workbarInstanceId)
await expect(harness.service.createTab('conversation', 22, new AbortController().signal, workbarInstanceId)).rejects.toThrow('不属于当前窗口')
await harness.service.dispose()
})
it('cleans unused reservations on conversation release, service clear, and disposal', async () => {
const harness = createHarness()
const first = harness.service.reserveRequestTab('first', 'first', 21)
await expect(harness.service.releaseConversation('first', 22)).rejects.toThrow('不属于当前窗口')
expect(first.signal.aborted).toBe(false)
await harness.service.releaseConversation('first', 21)
expect(first.signal.aborted).toBe(true)
expect(harness.service.getOwnerWindowId('first')).toBeUndefined()
const second = harness.service.reserveRequestTab('second', 'second', 21)
await harness.service.clearSessions()
expect(second.signal.aborted).toBe(true)
const third = harness.service.reserveRequestTab('third', 'third', 21)
await harness.service.dispose()
expect(third.signal.aborted).toBe(true)
expect(harness.createSession).not.toHaveBeenCalled()
expect(() => harness.service.reserveRequestTab('fourth', 'fourth', 21)).toThrow('已关闭')
})
it('abandons materialization when the final request lease is released', async () => {
const gate = deferred<void>()
const harness = createHarness({ sessionGate: gate.promise })
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const request = harness.service.reserveRequestTab('conversation', 'request', 21)
const navigation = harness.service.navigate('conversation', 'https://example.com/', new AbortController().signal, request.tabId)
const rejection = expect(navigation).rejects.toThrow('已取消')
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
request.release()
gate.resolve()
await rejection
expect(harness.service.getSessionCount()).toBe(0)
await vi.waitFor(() => expect(harness.slots[0]!.session.dispose).toHaveBeenCalledOnce())
expect(states.at(-1)?.status).toBe('stopped')
await harness.service.dispose()
})
it('preserves shared reserved materialization when one request cancels', async () => {
const gate = deferred<void>()
const harness = createHarness({ sessionGate: gate.promise })
const first = harness.service.reserveRequestTab('conversation', 'first', 21)
const second = harness.service.reserveRequestTab('conversation', 'second', 21)
const firstNavigation = harness.service.navigate('conversation', 'https://example.com/first', first.signal, first.tabId)
const canceled = expect(firstNavigation).rejects.toThrow('租约已终止')
const secondNavigation = harness.service.navigate('conversation', 'https://example.com/second', second.signal, second.tabId)
await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce())
first.release()
await canceled
gate.resolve()
await secondNavigation
expect(harness.service.listTabs('conversation', 21)).toEqual([
expect.objectContaining({ tabId: second.tabId, workbarInstanceId: second.tabId, url: 'https://example.com/second' })
])
expect(harness.slots[0]!.session.dispose).not.toHaveBeenCalled()
second.release()
await harness.service.dispose()
})
it('retains a materialized reserved tab until the request lease stops protecting it from idle expiry', async () => {
vi.useFakeTimers()
const harness = createHarness({ idleTimeoutMs: 100 })
const lease = harness.service.reserveRequestTab('conversation', 'request', 21)
await harness.service.navigate('conversation', 'https://example.com/', lease.signal, lease.tabId)
await vi.advanceTimersByTimeAsync(500)
expect(harness.service.getSessionCount()).toBe(1)
lease.release()
await vi.advanceTimersByTimeAsync(101)
expect(harness.service.getSessionCount()).toBe(0)
await harness.service.dispose()
})
it('keeps stale close idempotent but rejects another conversation or window', async () => {
const harness = createHarness()
const first = await harness.service.createTab('first', 21)
const sibling = await harness.service.createTab('first', 21)
const other = await harness.service.createTab('other', 21)
await harness.service.closeTab('first', sibling.tabId, 21)
await harness.service.closeTab('first', sibling.tabId, 21)
await expect(harness.service.closeTab('first', sibling.tabId, 22)).rejects.toThrow('不属于当前窗口')
await expect(harness.service.closeTab('first', other.tabId, 21)).rejects.toThrow('不属于当前对话')
await expect(harness.service.closeTab('missing', first.tabId, 21)).rejects.toThrow('不属于当前对话')
await harness.service.dispose()
})
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 }
@@ -219,7 +397,7 @@ describe('BrowserService', () => {
await harness.service.dispose()
})
it('publishes browser status and live frames through session cleanup', async () => {
it('publishes lightweight browser status through session cleanup', async () => {
const harness = createHarness()
const states: Array<{
status: string
@@ -261,9 +439,7 @@ describe('BrowserService', () => {
isLoading: false,
canGoBack: false
})
expect(states.filter((state) => state.status === 'ready').at(-1)?.frameDataUrl).toBe(
'data:image/jpeg;base64,/9j/2Q=='
)
expect(states.every((state) => state.frameDataUrl === undefined)).toBe(true)
expect(states.at(-1)?.frameDataUrl).toBeUndefined()
const replayed: string[] = []
const removeReplayListener = harness.service.onState((state) => {
@@ -275,163 +451,55 @@ describe('BrowserService', () => {
await harness.service.dispose()
})
it('does not publish ready after a session is stopped during frame capture', async () => {
it('ordinary actions only refresh navigation metadata and never capture images', async () => {
const nativeCapture = vi.fn(async () => {
throw new Error('native capture unavailable while hidden')
})
const harness = createHarness({ captureScreenshot: nativeCapture })
const signal = new AbortController().signal
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate('conversation', 'https://example.com/', signal)
await harness.service.snapshot('conversation', signal)
await harness.service.click('conversation', 'button_ref', signal)
await harness.service.type('conversation', 'input_ref', 'text', signal)
await harness.service.select('conversation', 'select_ref', 'value', signal)
await harness.service.reload('conversation', signal)
await harness.service.back('conversation', signal)
const slot = harness.slots[0]!
expect(nativeCapture).not.toHaveBeenCalled()
expect(slot.driver.screenshot).not.toHaveBeenCalled()
expect(slot.driver.getNavigationMetadata).toHaveBeenCalledTimes(7)
expect(states.at(-1)?.status).toBe('ready')
expect(states.every((state) => !('frameDataUrl' in state))).toBe(true)
const screenshot = await harness.service.screenshot('conversation', signal)
expect(screenshot.data).toBe('/9j/2Q==')
expect(nativeCapture).toHaveBeenCalledOnce()
expect(slot.driver.screenshot).toHaveBeenCalledOnce()
expect(states.at(-1)?.status).toBe('ready')
expect(states.every((state) => !('frameDataUrl' in state))).toBe(true)
await harness.service.dispose()
})
it('does not publish ready after a session is stopped during metadata refresh', async () => {
const harness = createHarness()
const signal = new AbortController().signal
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
const slot = harness.slots[0]
if (!slot) {
throw new Error('slot missing')
}
vi.mocked(slot.driver.screenshot).mockImplementationOnce(
async (operationSignal) =>
new Promise<never>((_resolve, reject) => {
operationSignal.addEventListener(
'abort',
() => reject(operationSignal.reason),
{ once: true }
)
})
)
const click = harness.service.click(
'conversation',
'button_ref',
signal
)
await vi.waitFor(() =>
expect(slot.driver.screenshot).toHaveBeenCalledTimes(2)
await harness.service.navigate('conversation', 'https://example.com/', signal)
const slot = harness.slots[0]!
vi.mocked(slot.driver.getNavigationMetadata).mockImplementationOnce(
async (operationSignal) => new Promise<never>((_resolve, reject) => {
operationSignal.addEventListener('abort', () => reject(operationSignal.reason), { once: true })
})
)
const click = harness.service.click('conversation', 'button_ref', signal)
const rejected = expect(click).rejects.toThrow('浏览器会话已释放')
await vi.waitFor(() => expect(slot.driver.getNavigationMetadata).toHaveBeenCalledTimes(2))
await harness.service.releaseConversation('conversation')
await expect(click).rejects.toThrow('浏览器会话已释放')
await rejected
expect(states.at(-1)?.status).toBe('stopped')
})
it('falls back to CDP when native capture cannot produce the live frame', async () => {
const nativeCapture = vi.fn(async () => {
throw new Error('native capture unavailable while hidden')
})
const harness = createHarness({
captureScreenshot: nativeCapture
})
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
expect(nativeCapture).toHaveBeenCalledOnce()
expect(harness.slots[0]?.driver.screenshot).toHaveBeenCalledOnce()
expect(states.at(-1)).toMatchObject({
status: 'ready',
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
})
await harness.service.dispose()
})
it('retries live capture while a newly committed page starts painting', async () => {
let attempts = 0
const harness = createHarness({
captureScreenshot: async () => {
attempts += 1
if (attempts === 1) {
throw new Error('page has not painted yet')
}
return {
type: 'image',
mimeType: 'image/jpeg',
data: '/9j/2Q=='
}
},
driverScreenshot: async () => {
throw new Error('CDP frame not ready')
}
})
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
expect(attempts).toBe(2)
expect(states.at(-1)).toMatchObject({
status: 'ready',
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
})
await harness.service.dispose()
})
it('reports a live-frame failure instead of waiting indefinitely', async () => {
const harness = createHarness({
captureScreenshot: async () => {
throw new Error('native capture failed')
},
driverScreenshot: async () => {
throw new Error('CDP capture failed')
}
})
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
await harness.service.navigate(
'conversation',
'https://example.com/',
new AbortController().signal
)
expect(states.at(-1)).toMatchObject({
status: 'failed',
error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作'
})
await harness.service.dispose()
})
it('keeps the last frame when a later refresh cannot capture a minimized window', async () => {
let nativeAttempts = 0
const harness = createHarness({
captureScreenshot: async () => {
nativeAttempts += 1
if (nativeAttempts === 1) {
return {
type: 'image',
mimeType: 'image/jpeg',
data: '/9j/2Q=='
}
}
throw new Error('minimized native capture unavailable')
},
driverScreenshot: async () => {
throw new Error('minimized CDP capture unavailable')
}
})
const states: BrowserLiveState[] = []
harness.service.onState((state) => states.push(state))
const signal = new AbortController().signal
await harness.service.navigate(
'conversation',
'https://example.com/',
signal
)
await harness.service.click('conversation', 'button_ref', signal)
expect(states.at(-1)).toMatchObject({
status: 'ready',
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
})
await harness.service.dispose()
})
@@ -1180,7 +1248,7 @@ describe('BrowserService', () => {
await harness.service.dispose()
})
it('does not resolve DNS again while refreshing frame metadata', async () => {
it('does not resolve DNS again while refreshing navigation metadata', async () => {
const harness = createHarness()
const signal = new AbortController().signal
await harness.service.navigate(
+177 -100
View File
@@ -84,7 +84,6 @@ export type BrowserServiceOptions = {
maximumTabsPerWindow?: number
idleTimeoutMs?: number
cleanupTimeoutMs?: number
liveFrameDelayMs?: number
parentWindow?: BrowserParentWindowHandle
createSession?: (
policy: BrowserUrlPolicy,
@@ -134,12 +133,20 @@ type BrowserConversationSlot = {
}
type ConversationCreation = {
tabId: BrowserTabId
controller: AbortController
promise: Promise<BrowserConversationSlot>
waiters: Set<symbol>
ownerWindowId?: number
}
type RequestTabReservation = Pick<
BrowserTabSlot,
'conversationId' | 'tabId' | 'usageLeases'
> & {
ownerWindowId?: number
}
type ViewportLease = {
leaseToken: string
ownerWindowId?: number
@@ -227,7 +234,6 @@ export class BrowserService {
private readonly maximumTabsPerWindow: number
private readonly idleTimeoutMs: number
private readonly cleanupTimeoutMs: number
private readonly liveFrameDelayMs: number
private readonly createSession: NonNullable<BrowserServiceOptions['createSession']>
private readonly createDriver: NonNullable<BrowserServiceOptions['createDriver']>
private readonly conversations = new Map<string, BrowserConversationSlot>()
@@ -238,6 +244,7 @@ export class BrowserService {
private readonly stateListeners = new Set<(state: BrowserLiveState) => void>()
private readonly liveStates = new Map<string, BrowserLiveState>()
private readonly ownedTabCreations = new Map<string, OwnedTabCreation>()
private readonly requestTabs = new Map<string, RequestTabReservation>()
private viewport?: ViewportLease
private lifecycle = new AbortController()
private clearOperation?: Promise<void>
@@ -253,7 +260,6 @@ export class BrowserService {
options.maximumTabsPerWindow ?? DEFAULT_MAXIMUM_TABS_PER_WINDOW
this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
this.cleanupTimeoutMs = options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS
this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100
this.createSession =
options.createSession ??
((policy, signal) => defaultCreateSession(policy, signal, options.parentWindow))
@@ -269,9 +275,6 @@ export class BrowserService {
throw new Error('浏览器服务限制配置无效')
}
}
if (!Number.isSafeInteger(this.liveFrameDelayMs) || this.liveFrameDelayMs < 0) {
throw new Error('浏览器服务限制配置无效')
}
}
getOrigin(conversationId: string, tabId?: BrowserTabId): string | undefined {
@@ -292,7 +295,8 @@ export class BrowserService {
getOwnerWindowId(conversationId: string): number | undefined {
return this.conversations.get(conversationId)?.ownerWindowId ??
this.creations.get(conversationId)?.ownerWindowId
this.creations.get(conversationId)?.ownerWindowId ??
this.requestTabs.get(conversationId)?.ownerWindowId
}
onState(listener: (state: BrowserLiveState) => void): () => void {
@@ -349,9 +353,9 @@ export class BrowserService {
}
private emitState(
tab: Pick<BrowserTabSlot, 'conversationId' | 'tabId'>,
tab: Pick<BrowserTabSlot, 'conversationId' | 'tabId' | 'workbarInstanceId'>,
status: BrowserLiveState['status'],
update: Partial<Pick<BrowserLiveState, 'url' | 'frameDataUrl' | 'error' | 'canGoBack' | 'isLoading'>> = {}
update: Partial<Pick<BrowserLiveState, 'url' | 'error' | 'canGoBack' | 'isLoading'>> = {}
): void {
const key = stateKey(tab.conversationId, tab.tabId)
const previous = this.liveStates.get(key)
@@ -359,6 +363,9 @@ export class BrowserService {
const state: BrowserLiveState = {
conversationId: tab.conversationId,
tabId: tab.tabId,
...((tab.workbarInstanceId ?? current?.workbarInstanceId ?? previous?.workbarInstanceId)
? { workbarInstanceId: tab.workbarInstanceId ?? current?.workbarInstanceId ?? previous?.workbarInstanceId }
: {}),
...(current
? this.conversations.get(tab.conversationId)?.ownerWindowId !== undefined
? { ownerWindowId: this.conversations.get(tab.conversationId)!.ownerWindowId }
@@ -375,7 +382,6 @@ export class BrowserService {
(status === 'creating' ? true : status === 'stopped' ? false : current?.isLoading ?? false),
canGoBack: update.canGoBack ?? previous?.canGoBack ?? false,
...(previous?.url ? { url: previous.url } : {}),
...(status !== 'stopped' && previous?.frameDataUrl ? { frameDataUrl: previous.frameDataUrl } : {}),
...update,
updatedAt: Date.now()
}
@@ -392,7 +398,7 @@ export class BrowserService {
}
private assertWindowOwner(
conversation: BrowserConversationSlot,
conversation: { ownerWindowId?: number },
ownerWindowId?: number
): void {
if (ownerWindowId === undefined) return
@@ -540,11 +546,19 @@ export class BrowserService {
private async getOrCreateConversation(
conversationId: string,
signal: AbortSignal,
ownerWindowId?: number
ownerWindowId?: number,
workbarInstanceId?: string,
requestTabId?: BrowserTabId
): Promise<BrowserConversationSlot> {
signal.throwIfAborted()
if (this.disposed || this.clearing || this.lifecycle.signal.aborted) {
throw new Error('浏览器服务已关闭')
}
const requestTab = this.requestTabs.get(conversationId)
if (requestTab) {
this.assertWindowOwner(requestTab, ownerWindowId)
ownerWindowId ??= requestTab.ownerWindowId
}
const existing = this.conversations.get(conversationId)
if (existing && !existing.released) {
this.assertWindowOwner(existing, ownerWindowId)
@@ -562,23 +576,30 @@ export class BrowserService {
const releaseReservation = this.reserveTab(conversationId, ownerWindowId)
const controller = new AbortController()
const waiters = new Set<symbol>()
const tabId = createTabId()
const tabId = requestTabId ?? createTabId()
const instanceId = workbarInstanceId ?? tabId
const promise = this.createConversation(
conversationId,
tabId,
ownerWindowId,
AbortSignal.any([this.lifecycle.signal, controller.signal]),
() => waiters.size > 0
() => waiters.size > 0,
instanceId
)
void promise.finally(releaseReservation).catch(() => undefined)
creation = { controller, promise, waiters, ownerWindowId }
creation = { tabId, controller, promise, waiters, ownerWindowId }
this.creations.set(conversationId, creation)
this.emitState({ conversationId, tabId }, 'creating')
this.emitState({ conversationId, tabId, workbarInstanceId: instanceId }, 'creating')
const current = creation
const remove = (): void => {
if (this.creations.get(conversationId) === current) this.creations.delete(conversationId)
}
void promise.then(remove, remove)
void promise.then(remove, () => {
remove()
if (this.liveStates.get(stateKey(conversationId, tabId))?.status === 'creating') {
this.emitState({ conversationId, tabId, workbarInstanceId: instanceId }, 'stopped')
}
})
}
const waiter = Symbol(conversationId)
creation.waiters.add(waiter)
@@ -600,7 +621,8 @@ export class BrowserService {
tabId: BrowserTabId,
ownerWindowId: number | undefined,
signal: AbortSignal,
hasWaiters: () => boolean
hasWaiters: () => boolean,
workbarInstanceId: string
): Promise<BrowserConversationSlot> {
const session = await this.createSession(this.policy, signal)
if (this.disposed || signal.aborted || !hasWaiters() || this.releaseRequests.has(conversationId)) {
@@ -618,7 +640,16 @@ export class BrowserService {
lastUsedAt: Date.now(),
released: false
}
const tab = await this.createTabSlot(undefined, conversationId, session, tabId)
const tab = await this.createTabSlot(undefined, conversationId, session, tabId, workbarInstanceId)
if (signal.aborted || !hasWaiters() || this.releaseRequests.has(conversationId)) {
tab.removeLoadingListener()
tab.removeNavigationListener()
tab.driver.dispose()
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs)
throw signal.reason ?? new Error('浏览器会话创建已取消')
}
this.adoptRequestTab(conversation, tab)
conversation.ownedTabs.set(workbarInstanceId, tabId)
conversation.tabs.set(tabId, tab)
this.conversations.set(conversationId, conversation)
this.emitState(tab, 'ready')
@@ -627,6 +658,14 @@ export class BrowserService {
return conversation
}
private adoptRequestTab(conversation: BrowserConversationSlot, tab: BrowserTabSlot): void {
const reservation = this.requestTabs.get(conversation.conversationId)
if (reservation?.tabId !== tab.tabId) return
tab.usageLeases = reservation.usageLeases
conversation.ownerWindowId ??= reservation.ownerWindowId
this.requestTabs.delete(conversation.conversationId)
}
async createTab(
conversationId: string,
ownerWindowId?: number,
@@ -636,6 +675,9 @@ export class BrowserService {
if (workbarInstanceId) {
const key = ownershipKey(conversationId, workbarInstanceId)
const existingConversation = this.conversations.get(conversationId)
if (existingConversation) this.assertWindowOwner(existingConversation, ownerWindowId)
const pendingConversation = this.creations.get(conversationId)
if (pendingConversation) this.assertWindowOwner(pendingConversation, ownerWindowId)
const existingTabId = existingConversation?.ownedTabs.get(workbarInstanceId)
const existingTab = existingTabId ? existingConversation?.tabs.get(existingTabId) : undefined
if (existingTab && !existingTab.released) return this.summarizeTab(existingTab)
@@ -677,17 +719,13 @@ export class BrowserService {
workbarInstanceId?: string
): Promise<BrowserTabSummary> {
const existed = this.conversations.has(conversationId) || this.creations.has(conversationId)
const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId)
const reservation = this.requestTabs.get(conversationId)
const requestTabId = reservation && reservation.tabId === workbarInstanceId ? reservation.tabId : undefined
const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId, workbarInstanceId, requestTabId)
if (workbarInstanceId) {
const ownedTabId = conversation.ownedTabs.get(workbarInstanceId)
const ownedTab = ownedTabId ? conversation.tabs.get(ownedTabId) : undefined
if (ownedTab && !ownedTab.released) return this.summarizeTab(ownedTab)
const primary = conversation.tabs.get(conversation.primaryTabId)
if (primary && !primary.workbarInstanceId && !existed) {
primary.workbarInstanceId = workbarInstanceId
conversation.ownedTabs.set(workbarInstanceId, primary.tabId)
return this.summarizeTab(primary)
}
} else if (!existed) {
return this.summarizeTab(conversation.tabs.get(conversation.primaryTabId)!)
}
@@ -709,9 +747,14 @@ export class BrowserService {
await boundedCleanup(session.dispose(), this.cleanupTimeoutMs)
throw effectiveSignal.reason ?? new Error('浏览器会话已关闭')
}
const tabId = createTabId()
this.emitState({ conversationId, tabId }, 'creating')
const tabId = requestTabId ?? createTabId()
this.emitState({ conversationId, tabId, workbarInstanceId }, 'creating')
const tab = await this.createTabSlot(conversation, conversationId, session, tabId, workbarInstanceId)
if (effectiveSignal.aborted || conversation.released) {
await this.releaseTab(conversation, tab)
throw effectiveSignal.reason ?? new Error('浏览器会话已关闭')
}
this.adoptRequestTab(conversation, tab)
if (workbarInstanceId) conversation.ownedTabs.set(workbarInstanceId, tabId)
this.touchConversation(conversationId)
this.emitState(tab, 'ready')
@@ -748,6 +791,36 @@ export class BrowserService {
return conversation.tabs.has(tabId) ? tabId : undefined
}
reserveRequestTab(
conversationId: string,
owner: string,
ownerWindowId?: number
): BrowserTabUsageLease {
if (this.disposed || this.clearing || this.releaseRequests.has(conversationId)) {
throw new Error('浏览器服务已关闭')
}
if (!owner || owner.length > 500) {
throw new Error('浏览器标签页使用租约所有者无效')
}
const conversation = this.conversations.get(conversationId)
let reservation = this.requestTabs.get(conversationId)
if (!reservation && conversation && !conversation.released) {
return this.acquireTabUsage(conversationId, conversation.primaryTabId, owner, ownerWindowId)
}
if (!reservation) {
const creation = this.creations.get(conversationId)
if (creation) this.assertWindowOwner(creation, ownerWindowId)
reservation = {
conversationId,
tabId: creation?.tabId ?? createTabId(),
ownerWindowId: ownerWindowId ?? creation?.ownerWindowId,
usageLeases: new Map()
}
this.requestTabs.set(conversationId, reservation)
}
return this.acquireTabUsage(conversationId, reservation.tabId, owner, ownerWindowId)
}
acquireTabUsage(
conversationId: string,
tabId: BrowserTabId,
@@ -758,18 +831,18 @@ export class BrowserService {
throw new Error('浏览器标签页使用租约所有者无效')
}
const conversation = this.conversations.get(conversationId)
if (!conversation || conversation.released) {
throw new Error('浏览器标签页不存在或不属于当前对话')
}
this.assertWindowOwner(conversation, ownerWindowId)
const tab = conversation.tabs.get(tabId)
if (!tab || tab.released) {
const reservation = this.requestTabs.get(conversationId)
if (conversation) this.assertWindowOwner(conversation, ownerWindowId)
if (reservation) this.assertWindowOwner(reservation, ownerWindowId)
const tab = conversation?.tabs.get(tabId) ??
(reservation?.tabId === tabId ? reservation : undefined)
if (!tab || conversation?.released) {
throw new Error('浏览器标签页不存在或不属于当前对话')
}
const token = Symbol(owner)
const controller = new AbortController()
tab.usageLeases.set(token, { owner, controller })
if (conversation.idleTimer) {
if (conversation?.idleTimer) {
clearTimeout(conversation.idleTimer)
conversation.idleTimer = undefined
}
@@ -782,9 +855,24 @@ export class BrowserService {
release: (): void => {
if (released) return
released = true
if (tab.usageLeases.delete(token) && this.hasTab(tab)) {
this.touchConversation(conversationId)
if (tab.usageLeases.delete(token)) {
if (this.conversations.get(conversationId)?.tabs.has(tabId)) {
this.touchConversation(conversationId)
} else if (
tab.usageLeases.size === 0 &&
this.requestTabs.get(conversationId) === tab
) {
this.requestTabs.delete(conversationId)
const creation = this.creations.get(conversationId)
if (creation?.tabId === tabId) {
creation.controller.abort(new Error('浏览器会话创建已取消'))
}
this.ownedTabCreations.get(ownershipKey(conversationId, tabId))?.controller.abort(
new Error('浏览器标签页创建已取消')
)
}
}
controller.abort(new Error('浏览器标签页使用租约已终止'))
}
}
}
@@ -794,6 +882,7 @@ export class BrowserService {
return {
conversationId: tab.conversationId,
tabId: tab.tabId,
...(tab.workbarInstanceId ? { workbarInstanceId: tab.workbarInstanceId } : {}),
primary: this.conversations.get(tab.conversationId)?.primaryTabId === tab.tabId,
status:
state?.status && state.status !== 'stopped' ? state.status : 'ready',
@@ -807,12 +896,25 @@ export class BrowserService {
async closeTab(conversationId: string, tabId: BrowserTabId, ownerWindowId?: number): Promise<void> {
const conversation = this.conversations.get(conversationId)
if (!conversation || conversation.released) throw new Error('浏览器标签页不存在或不属于当前对话')
this.assertWindowOwner(conversation, ownerWindowId)
const tab = conversation.tabs.get(tabId)
if (!tab) throw new Error('浏览器标签页不存在或不属于当前对话')
if (conversation) this.assertWindowOwner(conversation, ownerWindowId)
const reservation = this.requestTabs.get(conversationId)
if (reservation) this.assertWindowOwner(reservation, ownerWindowId)
const creation = this.creations.get(conversationId)
if (creation) this.assertWindowOwner(creation, ownerWindowId)
const tab = conversation?.tabs.get(tabId)
if (!tab) {
if (
[...this.conversations.values()].some((slot) => slot.tabs.has(tabId)) ||
[...this.creations.entries()].some(([id, slot]) => slot.tabId === tabId && id !== conversationId) ||
[...this.requestTabs.values()].some((slot) => slot.tabId === tabId && slot.conversationId !== conversationId)
) throw new Error('浏览器标签页不存在或不属于当前对话')
if (reservation?.tabId === tabId && reservation.usageLeases.size > 0) {
throw new Error(BROWSER_TAB_IN_USE_ERROR)
}
return
}
if (tab.usageLeases.size > 0) throw new Error(BROWSER_TAB_IN_USE_ERROR)
await this.releaseTab(conversation, tab)
await this.releaseTab(conversation!, tab)
}
private requireTab(conversationId: string, tabId?: BrowserTabId, ownerWindowId?: number): BrowserTabSlot {
@@ -948,6 +1050,15 @@ export class BrowserService {
): Promise<{ url: string; origin: string }> {
let tab: BrowserTabSlot | undefined
try {
if (tabId) {
const knownTab = this.conversations.get(conversationId)?.tabs.has(tabId) ||
this.requestTabs.get(conversationId)?.tabId === tabId ||
this.creations.get(conversationId)?.tabId === tabId
if (!knownTab) throw new Error('浏览器标签页不存在或不属于当前对话')
if (this.requestTabs.get(conversationId)?.tabId === tabId) {
await this.createTab(conversationId, ownerWindowId, signal, tabId)
}
}
const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId)
tab = conversation.tabs.get(tabId ?? conversation.primaryTabId)
if (!tab || tab.released) throw new Error('浏览器标签页不存在或不属于当前对话')
@@ -972,7 +1083,7 @@ export class BrowserService {
if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined)
throw new Error('浏览器快照来源与当前会话不一致')
}
await this.captureFrame(tab, effectiveSignal, target.href)
await this.refreshNavigationState(tab, effectiveSignal, target.href)
return { ...snapshot, url: target.href }
}, tabId, ownerWindowId)
}
@@ -981,7 +1092,7 @@ export class BrowserService {
await this.runInTab(conversationId, signal, 'acting', true, '浏览器点击', async (tab, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(tab)
await tab.driver.click(ref, effectiveSignal)
await this.captureFrame(tab, effectiveSignal)
await this.refreshNavigationState(tab, effectiveSignal)
}, tabId, ownerWindowId)
}
@@ -989,7 +1100,7 @@ export class BrowserService {
await this.runInTab(conversationId, signal, 'acting', false, '浏览器输入', async (tab, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(tab)
await tab.driver.type(ref, text, effectiveSignal)
await this.captureFrame(tab, effectiveSignal)
await this.refreshNavigationState(tab, effectiveSignal)
}, tabId, ownerWindowId)
}
@@ -997,7 +1108,7 @@ export class BrowserService {
await this.runInTab(conversationId, signal, 'acting', false, '浏览器选择', async (tab, effectiveSignal) => {
await this.verifyCurrentOriginOrRelease(tab)
await tab.driver.select(ref, value, effectiveSignal)
await this.captureFrame(tab, effectiveSignal)
await this.refreshNavigationState(tab, effectiveSignal)
}, tabId, ownerWindowId)
}
@@ -1030,7 +1141,7 @@ export class BrowserService {
}
}
screenshot ??= await tab.driver.screenshot(effectiveSignal)
await this.captureFrame(tab, effectiveSignal, undefined, screenshot)
await this.refreshNavigationState(tab, effectiveSignal)
return screenshot
}, tabId, ownerWindowId)
}
@@ -1082,7 +1193,7 @@ export class BrowserService {
const finalTarget = await this.policy.validateRedirect(result.url, signal)
if (tab.session.getCurrentOrigin() !== finalTarget.origin) throw new Error(originMismatchMessage)
tab.origin = finalTarget.origin
await this.captureFrame(tab, signal, finalTarget.url.href)
await this.refreshNavigationState(tab, signal, finalTarget.url.href)
return { url: finalTarget.url.href, origin: finalTarget.origin }
} catch (error) {
if (error instanceof BrowserNavigationStoppedError) {
@@ -1095,11 +1206,10 @@ export class BrowserService {
}
}
private async captureFrame(
private async refreshNavigationState(
tab: BrowserTabSlot,
signal: AbortSignal,
url?: string,
screenshot?: BrowserScreenshot
url?: string
): Promise<void> {
let committedUrl = url
const previous = this.liveStates.get(stateKey(tab.conversationId, tab.tabId))
@@ -1114,65 +1224,19 @@ export class BrowserService {
} catch {
signal.throwIfAborted()
}
let frame = screenshot
if (!frame) {
if (this.liveFrameDelayMs > 0) {
await waitFor(new Promise<void>((resolve) => setTimeout(resolve, this.liveFrameDelayMs)), signal)
}
const deadline = AbortSignal.any([signal, AbortSignal.timeout(6_000)])
for (let attempt = 0; attempt < 3 && !frame; attempt += 1) {
if (attempt > 0) {
try {
await waitFor(new Promise<void>((resolve) => setTimeout(resolve, attempt * 150)), deadline)
} catch {
signal.throwIfAborted()
break
}
}
if (tab.session.captureScreenshot) {
try {
frame = await tab.session.captureScreenshot(AbortSignal.any([deadline, AbortSignal.timeout(1_500)]))
} catch {
signal.throwIfAborted()
}
}
if (!frame && !deadline.aborted) {
try {
frame = await tab.driver.screenshot(AbortSignal.any([deadline, AbortSignal.timeout(1_500)]))
} catch {
signal.throwIfAborted()
}
}
}
}
signal.throwIfAborted()
if (!this.hasTab(tab)) return
if (!frame) {
if (previous?.frameDataUrl) {
this.emitState(tab, 'ready', {
...(committedUrl ? { url: committedUrl } : {}),
canGoBack,
frameDataUrl: previous.frameDataUrl
})
} else {
this.emitState(tab, 'failed', {
...(committedUrl ? { url: committedUrl } : {}),
canGoBack,
error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作'
})
}
return
}
this.emitState(tab, 'ready', {
...(committedUrl ? { url: committedUrl } : {}),
canGoBack,
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
canGoBack
})
}
async releaseConversation(conversationId: string, ownerWindowId?: number): Promise<void> {
const ownedConversation = this.conversations.get(conversationId)
if (ownedConversation) this.assertWindowOwner(ownedConversation, ownerWindowId)
const reservation = this.requestTabs.get(conversationId)
if (reservation) this.assertWindowOwner(reservation, ownerWindowId)
const creation = this.creations.get(conversationId)
if (
ownerWindowId !== undefined &&
@@ -1182,6 +1246,7 @@ export class BrowserService {
throw new Error('浏览器对话不属于当前窗口')
}
this.releaseRequests.add(conversationId)
this.releaseRequestTab(conversationId)
let emitted = false
try {
const pendingCreation = this.creations.get(conversationId)
@@ -1215,6 +1280,16 @@ export class BrowserService {
}
}
private releaseRequestTab(conversationId: string): void {
const reservation = this.requestTabs.get(conversationId)
if (!reservation) return
this.requestTabs.delete(conversationId)
for (const lease of reservation.usageLeases.values()) {
lease.controller.abort(new Error('浏览器标签页使用租约已终止'))
}
reservation.usageLeases.clear()
}
private async releaseTab(conversation: BrowserConversationSlot, tab: BrowserTabSlot): Promise<void> {
if (tab.released) return
tab.released = true
@@ -1287,6 +1362,7 @@ export class BrowserService {
private async performClearSessions(): Promise<void> {
this.clearing = true
this.lifecycle.abort(new Error('浏览器会话已清除'))
for (const id of this.requestTabs.keys()) this.releaseRequestTab(id)
const requested = new Set(this.creations.keys())
for (const id of requested) this.releaseRequests.add(id)
try {
@@ -1308,6 +1384,7 @@ export class BrowserService {
this.disposed = true
this.clearing = true
this.lifecycle.abort(new Error('浏览器服务已关闭'))
for (const id of this.requestTabs.keys()) this.releaseRequestTab(id)
for (const [id, creation] of this.creations) {
this.releaseRequests.add(id)
creation.controller.abort(new Error('浏览器服务已关闭'))
+109 -22
View File
@@ -710,7 +710,7 @@ describe('registerIpcHandlers computer capabilities', () => {
sessionActive: true,
isLoading: false,
canGoBack: false,
frameDataUrl: 'data:image/jpeg;base64,frame',
workbarInstanceId: browserTabId,
updatedAt: 1
})
expect(webContents.send).toHaveBeenCalledWith(
@@ -740,11 +740,12 @@ describe('registerIpcHandlers computer capabilities', () => {
sessionActive: true,
isLoading: false,
canGoBack: false,
frameDataUrl: 'data:image/jpeg;base64,frame',
workbarInstanceId: browserTabId,
updatedAt: 2
})
const repeatedFramePayload = webContents.send.mock.calls.at(-1)?.[1]
expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl')
expect(repeatedFramePayload).toHaveProperty('workbarInstanceId', browserTabId)
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: siblingBrowserTabId,
@@ -753,11 +754,11 @@ describe('registerIpcHandlers computer capabilities', () => {
sessionActive: true,
isLoading: false,
canGoBack: false,
frameDataUrl: 'data:image/jpeg;base64,frame',
workbarInstanceId: siblingBrowserTabId,
updatedAt: 2
})
expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty(
'frameDataUrl'
'workbarInstanceId', siblingBrowserTabId
)
browserStateListener?.({
conversationId: 'browser-conversation',
@@ -777,12 +778,12 @@ describe('registerIpcHandlers computer capabilities', () => {
sessionActive: true,
isLoading: false,
canGoBack: false,
frameDataUrl: 'data:image/jpeg;base64,frame',
workbarInstanceId: browserTabId,
updatedAt: 4
})
expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty(
'frameDataUrl',
'data:image/jpeg;base64,frame'
'workbarInstanceId',
browserTabId
)
await expect(
electronMocks.handlers.get(ipcChannels.browserStop)?.(event, {
@@ -4774,7 +4775,8 @@ describe('registerIpcHandlers Runtime customization', () => {
listPendingConversationQueueIds: vi.fn(() => []),
getProject: vi.fn(() => ({
id: projectId,
rootPath: 'C:\\ProjectWorkspace'
rootPath: 'C:\\ProjectWorkspace',
runtimeSelection: undefined as { provider: 'opencode' } | undefined
})),
getConversation: vi.fn(() => ({
id: conversationId,
@@ -4968,6 +4970,17 @@ describe('registerIpcHandlers Runtime customization', () => {
).rejects.toThrow('对话 Runtime 或 Project 已更改')
persistedRuntimeSelection = undefined
settingsStore.getResolvedSettings.mockResolvedValue({
provider: 'model',
modelProfiles: [],
opencodeBaseUrl: '',
workspacePath: 'C:\\DefaultWorkspace'
})
assistantDatabase.getProject.mockReturnValue({
id: projectId,
rootPath: 'C:\\ProjectWorkspace',
runtimeSelection: { provider: 'opencode' }
})
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
@@ -5887,6 +5900,83 @@ describe('registerIpcHandlers agent terminal state', () => {
)
})
it('persists remote usage with the inherited project Runtime without pinning the conversation', async () => {
const requestId = '00000000-0000-4000-8000-000000000790'
const conversationId = 'inherited-remote-usage'
const selection = { provider: 'opencode', profileId: '00000000-0000-4000-8000-000000000791' } as const
const provenance = {
source: 'remote-semantic-transcript',
bindingId: 'inherited-usage',
operationId: requestId,
semanticSequence: '1',
eventIndex: 0
} as const
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId, type: 'context-metrics',
contextTokens: 42, effectiveTriggerTokens: 1000,
compressionEnabled: false, source: 'provider',
remoteProvenance: provenance
} as const
yield {
requestId: request.requestId, type: 'done',
remoteProvenance: { ...provenance, semanticSequence: '2' }
} as const
}
}
const { harness, projectId } = createManagedSshHarness(selectedRuntime)
const project = harness.assistantDatabase.getProject(projectId)
harness.assistantDatabase.getProject.mockReturnValue({ ...project, runtimeSelection: selection })
harness.assistantDatabase.getConversation.mockReturnValue({
id: conversationId, projectId, messages: []
})
await harness.handler?.(trustedEvent(harness.webContents), {
...managedSshRequest(projectId, requestId, conversationId),
runtimeSelection: undefined,
currentUserMessageId: '00000000-0000-4000-8000-000000000792',
currentAssistantMessageId: '00000000-0000-4000-8000-000000000793'
})
await vi.waitFor(() => expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledWith(
expect.objectContaining({ event: expect.objectContaining({ type: 'done' }) })
))
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledWith(
expect.objectContaining({
conversationId,
runtimeSelection: selection,
event: expect.objectContaining({ type: 'context-metrics', contextTokens: 42 })
})
)
expect(harness.assistantDatabase.getConversation(conversationId)).not.toHaveProperty('runtimeSelection')
await harness.dispose()
})
it('advertises read-only workspace and delegation tools in the production Ask instruction', async () => {
const received: string[] = []
const runtime = {
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
async *run(request: { requestId: string; trustedInstructions?: string }) {
received.push(request.trustedInstructions ?? '')
yield { type: 'done', requestId: request.requestId } as const
}
}
const harness = createHarness(runtime)
const requestId = '00000000-0000-4000-8000-000000000794'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId, conversationId: 'ask-workspace', prompt: 'Read the source', workMode: 'ask'
})
await vi.waitFor(() => expect(received).toHaveLength(1))
for (const name of ['workspace_rg', 'workspace_read_text', 'output_read', 'subagent_delegate']) {
expect(received[0]).toContain(name)
}
expect(received[0]).not.toContain('workspace_apply_patch')
expect(received[0]).not.toContain('process_execute')
await harness.dispose()
})
it('durably commits remote text before resume, deduplicates it, and preserves its semantic payload', async () => {
const requestId = '00000000-0000-4000-8000-000000000763'
const provenance = {
@@ -7650,18 +7740,18 @@ describe('registerIpcHandlers agent terminal state', () => {
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 createTab = vi.fn()
const browserControl = {
listTabs: vi.fn(() => []),
getVisibleTabId: vi.fn(() => undefined),
createTab,
reserveRequestTab: vi.fn((conversationId: string, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release
})),
acquireTabUsage: vi.fn(
(conversationId: string, acquiredTabId: BrowserTabId, owner: string) => ({
conversationId,
@@ -7702,13 +7792,10 @@ describe('registerIpcHandlers agent terminal state', () => {
}
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(
expect(createTab).not.toHaveBeenCalled()
expect(browserControl.acquireTabUsage).not.toHaveBeenCalled()
expect(browserControl.reserveRequestTab).toHaveBeenCalledWith(
`browser-${outcome}`,
tabId,
requestId,
harness.webContents.id
)
+37 -53
View File
@@ -14,7 +14,7 @@ import {
realpath,
stat
} from 'node:fs/promises'
import { createHash, randomUUID } from 'node:crypto'
import { randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { basename, extname, isAbsolute, join } from 'node:path'
import { z } from 'zod'
@@ -464,12 +464,11 @@ type BrowserRequestTabUsageLease = {
}
type BrowserCapabilityControl = {
createTab(
reserveRequestTab(
conversationId: string,
ownerWindowId?: number,
signal?: AbortSignal,
workbarInstanceId?: string
): Promise<{ tabId: BrowserTabId }>
owner: string,
ownerWindowId?: number
): BrowserRequestTabUsageLease
listTabs(
conversationId: string,
ownerWindowId?: number
@@ -486,18 +485,6 @@ type BrowserCapabilityControl = {
): 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
@@ -509,6 +496,7 @@ async function bindBrowserTab(input: {
if (!input.control) {
throw new Error('GoodBuddy 内置浏览器服务不可用')
}
input.signal.throwIfAborted()
const tabs = input.control.listTabs(
input.conversationId,
input.ownerWindowId
@@ -525,17 +513,17 @@ async function bindBrowserTab(input: {
input.ownerWindowId
)
const primary = tabs.find((tab) => tab.primary)
tabId = visibleTabId && tabs.some((tab) => tab.tabId === visibleTabId)
const existingTabId = visibleTabId && tabs.some((tab) => tab.tabId === visibleTabId)
? visibleTabId
: primary?.tabId ??
(
await input.control.createTab(
input.conversationId,
input.ownerWindowId,
input.signal,
requestWorkbarInstanceId(input.owner)
)
).tabId
: primary?.tabId
if (!existingTabId) {
return input.control.reserveRequestTab(
input.conversationId,
input.owner,
input.ownerWindowId
)
}
tabId = existingTabId
}
return input.control.acquireTabUsage(
input.conversationId,
@@ -1573,26 +1561,9 @@ export function registerIpcHandlers(
}
window.on('maximize', notifyMaximizedChanged)
window.on('unmaximize', notifyMaximizedChanged)
const lastSentBrowserFrames = new Map<string, string>()
const removeBrowserStateListener = browserControl?.onState((state) => {
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(tabStateKey) === frame
) {
payload = { ...state }
delete payload.frameDataUrl
}
if (frame) {
lastSentBrowserFrames.set(tabStateKey, frame)
}
if (state.status === 'stopped') {
lastSentBrowserFrames.delete(tabStateKey)
}
window.webContents.send(ipcChannels.browserState, payload)
window.webContents.send(ipcChannels.browserState, state)
}
})
const abortActiveRequests = (
@@ -2069,6 +2040,7 @@ export function registerIpcHandlers(
assistantDatabase.appendRemoteConversationTaskEventOnce({
taskId: task.taskId,
conversationId: task.conversationId,
runtimeSelection,
assistantMessageId: task.currentAssistantMessageId,
bindingId: provenance.bindingId,
operationId: provenance.operationId,
@@ -2513,7 +2485,12 @@ export function registerIpcHandlers(
abort: (reason) => controller.abort(reason)
})
knowledgeCapabilityToken = notesCapability.token
const noteTools = notesCapability.toolNames
const noteTools = [
...(!agentRuntimeSelected
? ['workspace_rg', 'workspace_read_text', 'output_read', 'subagent_delegate']
: []),
...notesCapability.toolNames
]
const noteToolSummary = noteTools.join(', ')
const modeInstruction =
schedule.workMode === 'execute'
@@ -4079,6 +4056,9 @@ export function registerIpcHandlers(
const knowledgeCapabilityToken = scopedCapability.token
const availableTools = [
...(webSearchEnabled ? ['web_search', 'web_fetch'] : []),
...(!agentRuntimeSelected && !imageGeneration
? ['workspace_rg', 'workspace_read_text', 'output_read', 'subagent_delegate']
: []),
...scopedCapability.toolNames
]
const hasAvailableTools = availableTools.length > 0
@@ -4263,6 +4243,7 @@ export function registerIpcHandlers(
? assistantDatabase.appendRemoteConversationTaskEventOnce({
taskId: request.requestId,
conversationId: request.conversationId,
runtimeSelection: request.runtimeSelection ?? configProject?.runtimeSelection,
assistantMessageId:
remoteConversationRecovery.currentAssistantMessageId,
bindingId: provenance.bindingId,
@@ -4290,6 +4271,7 @@ export function registerIpcHandlers(
? assistantDatabase.appendRemoteConversationTaskEventOnce({
taskId: request.requestId,
conversationId: request.conversationId,
runtimeSelection: request.runtimeSelection ?? configProject?.runtimeSelection,
assistantMessageId:
remoteConversationRecovery.currentAssistantMessageId,
bindingId: provenance.bindingId,
@@ -4911,9 +4893,15 @@ export function registerIpcHandlers(
request.conversationId
)
const settings = await settingsStore.getResolvedSettings()
const project = conversation.projectId
? assistantDatabase.getProject(conversation.projectId)
: undefined
const savedRuntimeSelection =
conversation.runtimeSelection ?? project?.runtimeSelection
const persistedRuntimeSelection =
conversation.runtimeSelection ??
getDefaultRuntimeSelection(settings)
savedRuntimeSelection && savedRuntimeSelection.provider !== 'auto'
? savedRuntimeSelection
: getDefaultRuntimeSelection(settings)
if (
conversation.projectId !== request.projectId ||
agentRuntimeSelectionKey(persistedRuntimeSelection) !==
@@ -4946,9 +4934,6 @@ export function registerIpcHandlers(
settings,
request.runtimeSelection
)
const project = request.projectId
? assistantDatabase.getProject(request.projectId)
: undefined
if (project?.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
@@ -8520,7 +8505,6 @@ export function registerIpcHandlers(
removeLocalToolEnvironmentProgressListener?.()
await localToolEnvironmentService?.dispose()
removeBrowserStateListener?.()
lastSentBrowserFrames.clear()
removeRemoteAgentConnectionStatusListener?.()
clearInterval(scheduleInterval)
for (const timeout of queueDispatchTimers.values()) {
+92
View File
@@ -732,6 +732,98 @@ describe('RuntimeSettingsStore', () => {
})
})
it.each([
{ name: 'complete administrator override', environment: {
GOODBUDDY_MODEL_API_KEY: 'administrator-secret',
GOODBUDDY_MODEL_BASE_URL: 'https://administrator.example/v1?private=marker',
GOODBUDDY_MODEL_NAME: 'administrator-model'
}, compatibleDefault: true, expected: 'environment' },
{ name: 'API key only with incompatible default', environment: {
GOODBUDDY_MODEL_API_KEY: 'administrator-secret'
}, compatibleDefault: false, expected: 'first' },
{ name: 'API key only with compatible default', environment: {
GOODBUDDY_MODEL_API_KEY: 'administrator-secret'
}, compatibleDefault: true, expected: 'default' },
{ name: 'address and model without API key', environment: {
GOODBUDDY_MODEL_BASE_URL: 'https://administrator.example/v1',
GOODBUDDY_MODEL_NAME: 'administrator-model'
}, compatibleDefault: false, expected: 'first' },
{ name: 'legacy environment credentials', environment: {
GOODBUDDY_BIGTOKEN_API_KEY: 'legacy-secret',
GOODBUDDY_BIGTOKEN_BASE_URL: 'https://legacy.example/v1',
GOODBUDDY_BIGTOKEN_MODEL: 'legacy-model'
}, compatibleDefault: false, expected: 'first' },
{ name: 'compatible saved default', environment: {},
compatibleDefault: true, expected: 'default' },
{ name: 'first compatible saved connection', environment: {},
compatibleDefault: false, expected: 'first' },
{ name: 'no compatible connection', environment: {},
compatibleDefault: false, expected: 'unavailable' }
])('projects the actual Harness platform source: $name', async ({
environment, compatibleDefault, expected
}) => {
const { store, filePath } = await createStore(environment)
const firstId = '00000000-0000-4000-8000-000000000061'
const defaultId = '00000000-0000-4000-8000-000000000062'
const profile = {
name: 'First compatible', baseUrl: 'https://saved.example/v1?private=marker',
modelName: 'saved-model', protocol: 'openai-chat-completions' as const,
authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const,
apiKey: { action: 'replace' as const, value: 'saved-secret' },
requestHeaders: { 'X-Private-Header': 'header-marker' },
requestBody: { privateMetadata: 'body-marker' }
}
const input = settings({
modelProfiles: [
...(expected === 'unavailable' ? [] : [{ ...profile, id: firstId }]),
{
...profile, id: defaultId, name: 'Preferred connection',
protocol: compatibleDefault ? 'openai-chat-completions' : 'anthropic-messages'
}
],
defaultModelProfileId: defaultId
})
const publicSettings = await store.update(input)
const resolved = (await store.getResolvedSettings()).deepseekHarnessModelProfile
const projection = publicSettings.deepseekHarnessPlatformModel
if (expected === 'unavailable') {
expect(resolved).toBeUndefined()
expect(projection).toEqual({ source: 'unavailable' })
} else {
expect(resolved).toBeDefined()
expect(projection).toEqual({
source: expected === 'environment' ? 'environment' : 'profile',
...(expected === 'environment'
? {}
: { profileId: expected === 'default' ? defaultId : firstId }),
name: resolved!.name,
modelName: resolved!.modelName
})
if (expected === 'environment') {
expect(projection?.source).toBe('environment')
expect(resolved?.modelName).toBe('administrator-model')
}
}
const serialized = JSON.stringify(projection)
for (const excluded of [
'https://', 'apiKey', 'baseUrl', 'requestHeaders', 'requestBody',
'administrator-secret', 'saved-secret', 'legacy-secret', 'header-marker', 'body-marker'
]) {
expect(serialized).not.toContain(excluded)
}
expect(await readFile(filePath, 'utf8')).not.toContain('deepseekHarnessPlatformModel')
expect((await store.getPublicSettings()).deepseekHarnessPlatformModel).toEqual(projection)
// Merely inspecting the platform option must not change a selected default rule.
if (expected !== 'unavailable') {
const followingDefault = await store.update({
...input, deepseekHarnessModelSource: { kind: 'default' }
})
expect(followingDefault.deepseekHarnessModelSource).toEqual({ kind: 'default' })
expect(followingDefault.deepseekHarnessPlatformModel).toEqual(projection)
}
})
it('resolves a controlled platform Harness profile without exposing its credential', async () => {
const apiKey = 'platform-harness-secret'
const { store } = await createStore({
+20
View File
@@ -1740,6 +1740,25 @@ export class RuntimeSettingsStore {
}
})
const deepseekHarnessModelSource = settings.deepseekHarnessModelSource
const harnessPlatformProfile = this.resolveDeepSeekHarnessModelProfile(
{ ...settings, deepseekHarnessModelSource: { kind: 'platform' } },
resolvedModelProfiles
)
const deepseekHarnessPlatformModel: RuntimeSettings['deepseekHarnessPlatformModel'] =
!harnessPlatformProfile
? { source: 'unavailable' }
: harnessPlatformProfile.id === platformHarnessProfileId
? {
source: 'environment',
name: harnessPlatformProfile.name,
modelName: harnessPlatformProfile.modelName
}
: {
source: 'profile',
profileId: harnessPlatformProfile.id,
name: harnessPlatformProfile.name,
modelName: harnessPlatformProfile.modelName
}
const embeddingEnvironmentApiKey =
this.environment.GOODBUDDY_EMBEDDING_API_KEY?.trim()
const embeddingStoredApiKey = embeddingEnvironmentApiKey
@@ -1846,6 +1865,7 @@ export class RuntimeSettingsStore {
: settings.opencodeModelSource,
continueModelSource: settings.continueModelSource,
deepseekHarnessModelSource,
deepseekHarnessPlatformModel,
secureStorageAvailable: this.cipher.isAvailable(),
toolApproval: settings.toolApproval,
configured: {
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { link, lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
@@ -14,7 +14,7 @@ async function repository(): Promise<string> {
return root
}
function git(root: string, ...args: string[]): string {
return execFileSync('git', ['-C', root, '-c', 'user.name=Workspace Test', '-c', 'user.email=workspace@example.invalid', ...args], { encoding: 'utf8', windowsHide: true }).trim()
return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', windowsHide: true }).trim()
}
it('creates, moves, inspects and deletes without overwriting an existing file', async () => {
const root = await repository()
@@ -35,7 +35,7 @@ it('reads unborn history, initial commit files and literal paths, and switches w
git(root, 'add', '.')
git(root, 'commit', '-m', 'Initial subject')
const oid = git(root, 'rev-parse', 'HEAD')
await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ head: oid, commits: [{ subject: 'Initial subject', author: 'Workspace Test' }] })
await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ head: oid, commits: [{ subject: 'Initial subject', author: git(root, 'log', '-1', '--format=%an') }] })
await expect(manageWorkspace(root, { kind: 'commitFiles', oid })).resolves.toMatchObject({ files: [{ path: '[test].txt', status: 'A' }] })
await expect(manageWorkspace(root, { kind: 'commitDiff', oid, path: '[test].txt' })).resolves.toMatchObject({ patch: expect.stringContaining('+initial') })
await writeFile(join(root, '[test].txt'), 'dirty\n')
@@ -65,3 +65,88 @@ it('reports switch conflicts without discarding dirty content and pages stable h
const second = await manageWorkspace(root, { kind: 'history', offset: 50, head: first.head })
expect(second).toMatchObject({ hasMore: false, commits: [{ subject: 'page 0' }, { subject: 'initial' }] })
}, 30_000)
it.each([false, true])('preserves rename metadata and actual edits in a literal commit diff (edited: %s)', async (edited) => {
const root = await repository()
const previousPath = '[old];$(echo).txt'
const path = '[new];$(echo).txt'
const original = Array.from({ length: 20 }, (_, index) => `original line ${index}\n`).join('')
await writeFile(join(root, previousPath), original)
await writeFile(join(root, 'unrelated.txt'), 'unrelated before\n')
git(root, 'add', '.'); git(root, 'commit', '-m', 'before rename')
await rename(join(root, previousPath), join(root, path))
if (edited) await writeFile(join(root, path), original.replace('original line 10\n', 'edited line 10\n'))
await writeFile(join(root, 'unrelated.txt'), 'unrelated after\n')
git(root, 'add', '.'); git(root, 'commit', '-m', 'rename')
const oid = git(root, 'rev-parse', 'HEAD')
const files = await manageWorkspace(root, { kind: 'commitFiles', oid })
expect(files).toMatchObject({ files: expect.arrayContaining([{ path, previousPath, status: edited ? expect.stringMatching(/^R\d+$/) : 'R100' }]) })
const result = await manageWorkspace(root, { kind: 'commitDiff', oid, path })
expect(result.kind).toBe('commitDiff')
if (result.kind !== 'commitDiff') return
expect(result.truncated).toBe(false)
expect(result.patch).toContain(`rename from ${previousPath}`)
expect(result.patch).toContain(`rename to ${path}`)
expect(result.patch).not.toContain('new file mode')
expect(result.patch).not.toContain('unrelated')
if (edited) {
expect(result.patch).toContain('-original line 10')
expect(result.patch).toContain('+edited line 10')
expect(result.patch).not.toContain('+original line 0')
} else {
expect(result.patch).toContain('similarity index 100%')
expect(result.patch).not.toContain('@@')
}
})
it.each(['file', 'directory'] as const)('renames a %s by case only on the current filesystem', async (type) => {
const root = await repository()
const source = join(root, 'MixedCase')
if (type === 'directory') {
await mkdir(source)
await writeFile(join(source, 'keep.txt'), 'keep')
} else await writeFile(source, 'keep')
const alias = await lstat(join(root, 'mixedcase')).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return undefined
})
if (alias) expect(alias.ino).toBe((await lstat(source)).ino)
await expect(manageWorkspace(root, { kind: 'move', path: 'MixedCase', destination: 'mixedcase' })).resolves.toEqual({ kind: 'done' })
const names = await readdir(root)
expect(names).toContain('mixedcase')
expect(names).not.toContain('MixedCase')
expect(await readFile(join(root, 'mixedcase', ...(type === 'directory' ? ['keep.txt'] : [])), 'utf8')).toBe('keep')
})
it.each(['file', 'hardlink', 'directory'] as const)('rejects moving over a separate existing %s without changing either entry', async (type) => {
const root = await repository()
if (type === 'directory') {
await mkdir(join(root, 'source'))
await mkdir(join(root, 'destination'))
await writeFile(join(root, 'source/keep.txt'), 'source')
await writeFile(join(root, 'destination/keep.txt'), 'destination')
} else {
await writeFile(join(root, 'source'), 'source')
if (type === 'hardlink') await link(join(root, 'source'), join(root, 'destination'))
else await writeFile(join(root, 'destination'), 'destination')
}
await expect(manageWorkspace(root, { kind: 'move', path: 'source', destination: 'destination' })).rejects.toThrow('Destination already exists')
expect(await readFile(join(root, type === 'directory' ? 'source/keep.txt' : 'source'), 'utf8')).toBe('source')
expect(await readFile(join(root, type === 'directory' ? 'destination/keep.txt' : 'destination'), 'utf8')).toBe(type === 'hardlink' ? 'source' : 'destination')
})
it.for([false, true])('rejects a distinct case-differing destination on case-sensitive filesystems (hardlink: %s)', async (hardlink, context) => {
const root = await repository()
await writeFile(join(root, 'Case.txt'), 'source')
const alias = await lstat(join(root, 'case.txt')).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return undefined
})
if (alias) return context.skip()
if (hardlink) await link(join(root, 'Case.txt'), join(root, 'case.txt'))
else await writeFile(join(root, 'case.txt'), 'destination')
await expect(manageWorkspace(root, { kind: 'move', path: 'Case.txt', destination: 'case.txt' })).rejects.toThrow('Destination already exists')
expect(await readdir(root)).toEqual(expect.arrayContaining(['Case.txt', 'case.txt']))
expect(await readFile(join(root, 'Case.txt'), 'utf8')).toBe('source')
expect(await readFile(join(root, 'case.txt'), 'utf8')).toBe(hardlink ? 'source' : 'destination')
})
+35 -20
View File
@@ -1,6 +1,6 @@
import { execFile } from 'node:child_process'
import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises'
import { basename, dirname, resolve } from 'node:path'
import { workspaceManagementActionSchema, type WorkspaceManagementAction, type WorkspaceManagementResult } from '../../shared/workspace-management-contracts'
import { getCanonicalWorkspace, isPathInside } from '../workspace-file-access'
@@ -18,6 +18,18 @@ export async function manageWorkspace(rootPath: string, input: WorkspaceManageme
else resolveOutput(stdout)
})
})
const commitFiles = async (oid: string): Promise<Extract<WorkspaceManagementResult, { kind: 'commitFiles' }>> => {
const output = await git(['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-m', '--first-parent', '-M', '-z', oid])
const fields = output.split('\0')
const files: Extract<WorkspaceManagementResult, { kind: 'commitFiles' }>['files'] = []
for (let index = 0; index + 1 < fields.length;) {
const status = fields[index++]!
const first = fields[index++]!
const renamed = /^[RC]/.test(status)
files.push({ status, path: renamed ? fields[index++]! : first, ...(renamed ? { previousPath: first } : {}) })
}
return { kind: 'commitFiles', files }
}
if (['createFile', 'createDirectory', 'move', 'delete', 'properties'].includes(action.kind) && 'path' in action) {
const target = resolve(root, action.path)
const parent = await realpath(dirname(target))
@@ -36,13 +48,24 @@ export async function manageWorkspace(rootPath: string, input: WorkspaceManageme
const destination = resolve(root, action.destination)
const destinationParent = await realpath(dirname(destination))
if (!isPathInside(root, destinationParent) || !isPathInside(root, destination) || destination === root) throw new Error('Destination is outside the workspace')
try { await lstat(destination) } catch (error) {
const destinationMetadata = await lstat(destination).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
signal?.throwIfAborted()
await rename(target, destination)
return { kind: 'done' }
return undefined
})
if (destinationMetadata) {
const sourceName = basename(target)
const destinationName = basename(destination)
// Equal inodes alone also match separate hard links. Permit only a
// differently cased alias of the same entry, not an existing name.
const caseAlias = parent === destinationParent && sourceName !== destinationName &&
sourceName.toLowerCase() === destinationName.toLowerCase() &&
metadata.dev === destinationMetadata.dev && metadata.ino === destinationMetadata.ino
const names = caseAlias ? await readdir(parent) : []
if (!caseAlias || !names.includes(sourceName) || names.includes(destinationName)) throw new Error('Destination already exists')
}
throw new Error('Destination already exists')
signal?.throwIfAborted()
await rename(target, destination)
return { kind: 'done' }
}
}
return { kind: 'done' }
@@ -86,20 +109,12 @@ export async function manageWorkspace(rootPath: string, input: WorkspaceManageme
for (let index = 0; index + 4 < fields.length; index += 5) commits.push({ oid: fields[index]!.trim(), subject: fields[index + 1]!, author: fields[index + 2]!, time: fields[index + 3]!, refs: fields[index + 4]! })
return { kind: 'history', head, commits: commits.slice(0, 50), hasMore: commits.length > 50 }
}
case 'commitFiles': {
const output = await git(['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-m', '--first-parent', '-M', '-z', action.oid])
const fields = output.split('\0')
const files: Extract<WorkspaceManagementResult, { kind: 'commitFiles' }>['files'] = []
for (let index = 0; index + 1 < fields.length;) {
const status = fields[index++]!
const first = fields[index++]!
const renamed = /^[RC]/.test(status)
files.push({ status, path: renamed ? fields[index++]! : first, ...(renamed ? { previousPath: first } : {}) })
}
return { kind: 'commitFiles', files }
}
case 'commitFiles':
return commitFiles(action.oid)
case 'commitDiff': {
const patch = await git(['show', '--format=', '--first-parent', '--diff-merges=first-parent', '--no-ext-diff', '--no-textconv', action.oid, '--', action.path])
const file = (await commitFiles(action.oid)).files.find((file) => file.path === action.path)
const paths = file?.previousPath ? [file.previousPath, file.path] : [action.path]
const patch = await git(['show', '--format=', '--first-parent', '--diff-merges=first-parent', '-M', '--no-ext-diff', '--no-textconv', action.oid, '--', ...paths])
return { kind: 'commitDiff', patch: patch.slice(0, 256 * 1024), truncated: patch.length > 256 * 1024 }
}
default: throw new Error('Unsupported workspace action')
+8 -4
View File
@@ -125,6 +125,7 @@ import { UiLocaleProvider } from "./i18n/UiLocaleProvider";
let agentListener: ((event: AgentEvent) => void) | undefined;
let browserListener: ((state: BrowserLiveState) => void) | undefined;
const browserListeners = new Set<(state: BrowserLiveState) => void>();
let fileSelectionProgressListener:
Parameters<DesktopApi["context"]["onFileSelectionProgress"]>[0] | undefined;
let newConversationListener: (() => void) | undefined;
@@ -269,9 +270,12 @@ const api: DesktopApi = {
setViewport: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
onState: vi.fn((listener) => {
browserListener = listener;
browserListeners.add(listener);
browserListener = (state) => {
for (const notify of browserListeners) notify(state);
};
return () => {
browserListener = undefined;
browserListeners.delete(listener);
};
}),
},
@@ -1161,6 +1165,7 @@ describe("App", () => {
newConversationListener = undefined;
beforeQuitListener = undefined;
browserListener = undefined;
browserListeners.clear();
fileSelectionProgressListener = undefined;
maximizedChangedListener = undefined;
speechRecognitionMocks.startPcmRecording.mockResolvedValue({
@@ -1690,7 +1695,7 @@ describe("App", () => {
const taskRegion = await screen.findByRole("region", {
name: "当前会话的任务",
});
expect(within(taskRegion).getByText("Execute")).toBeInTheDocument();
expect(within(taskRegion).getByText("Ask")).toBeInTheDocument();
});
it("refreshes scheduled Task state on queue-only events and guards manual runs through completion", async () => {
@@ -10564,7 +10569,6 @@ describe("App", () => {
createBrowserState(conversationId ?? "", {
canGoBack: false,
url: "https://example.com/",
frameDataUrl: "data:image/jpeg;base64,/9j/2Q==",
updatedAt: Date.now(),
}),
);
+9 -7
View File
@@ -6032,17 +6032,11 @@ function App(): React.JSX.Element {
}
return next;
}
const previous = conversationStates[tabId];
return {
...current,
[state.conversationId]: {
...conversationStates,
[tabId]: state.frameDataUrl || !previous?.frameDataUrl
? state
: {
...state,
frameDataUrl: previous.frameDataUrl,
},
[tabId]: state,
},
};
});
@@ -8989,6 +8983,14 @@ function App(): React.JSX.Element {
taskStrip={
!conversation.remote ? (
<ConversationTaskStrip
conversationMode={
conversation.id === activeId
? effectiveWorkMode
: normalizeInteractiveWorkMode(
conversation.workMode ??
projects.find((project) => project.id === conversation.projectId)?.defaultWorkMode
)
}
locale={locale}
onRemoveSchedule={removeAssistantSchedule}
onRunSchedule={runAssistantSchedule}
@@ -6,6 +6,7 @@ import {
waitFor
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssistantSchedule, AssistantTask } from '../../shared/assistant-contracts'
import { ConversationTaskStrip } from './ConversationTaskStrip'
afterEach(cleanup)
@@ -23,6 +24,7 @@ describe('ConversationTaskStrip', () => {
render(
<ConversationTaskStrip
conversationMode="execute"
locale="zh-CN"
onRemoveSchedule={onRemoveSchedule}
onRunSchedule={onRunSchedule}
@@ -107,4 +109,87 @@ describe('ConversationTaskStrip', () => {
screen.queryByRole('region', { name: '当前会话的任务' })
).not.toBeInTheDocument()
})
it.each(['running', 'waiting_approval', 'completed', 'failed', 'interrupted'] as const)(
'shows the actual %s Task mode, not the schedule or current conversation mode',
(status) => {
const { task, schedule, props } = modeFixtures()
render(
<ConversationTaskStrip
{...props}
conversationMode="execute"
schedules={[schedule]}
tasks={[{ ...task, status, workMode: 'ask' }]}
/>
)
expect(screen.getByText('Ask')).toBeInTheDocument()
expect(screen.queryByText('Execute')).not.toBeInTheDocument()
}
)
it('tracks current conversation mode for an unrun schedule instead of its legacy mode', () => {
const { task, schedule, props } = modeFixtures()
const { rerender } = render(
<ConversationTaskStrip
{...props}
conversationMode="ask"
schedules={[schedule]}
tasks={[task]}
/>
)
expect(screen.getByText('Ask')).toBeInTheDocument()
expect(screen.queryByText('Execute')).not.toBeInTheDocument()
rerender(
<ConversationTaskStrip
{...props}
conversationMode="execute"
schedules={[{ ...schedule, workMode: 'ask' }]}
tasks={[task]}
/>
)
expect(screen.getByText('Execute')).toBeInTheDocument()
})
it('does not invent a mode when the conversation or historical Task mode is unavailable', () => {
const { task, schedule, props } = modeFixtures()
const { rerender } = render(
<ConversationTaskStrip {...props} schedules={[schedule]} tasks={[task]} />
)
expect(screen.queryByText('Execute')).not.toBeInTheDocument()
expect(screen.queryByText('Ask')).not.toBeInTheDocument()
rerender(
<ConversationTaskStrip
{...props}
conversationMode="execute"
schedules={[schedule]}
tasks={[{ ...task, status: 'completed', workMode: undefined }]}
/>
)
expect(screen.queryByText('Execute')).not.toBeInTheDocument()
expect(screen.queryByText('Ask')).not.toBeInTheDocument()
})
})
function modeFixtures() {
const task: AssistantTask = {
id: 'task', conversationId: 'conversation', scheduleId: 'schedule',
title: '模式回归', instructions: '', origin: 'schedule', status: 'idle',
workMode: 'execute', createdAt: '2026-08-19T00:00:00.000Z'
}
const schedule: AssistantSchedule = {
id: 'schedule', taskId: task.id, conversationId: 'conversation',
title: task.title, prompt: '', workMode: 'execute', recurrence: 'daily',
nextRunAt: '2026-08-20T09:00:00.000Z', enabled: true,
createdAt: task.createdAt, updatedAt: task.createdAt
}
return {
task, schedule,
props: {
locale: 'zh-CN', selectedTaskId: task.id,
onRemoveSchedule: vi.fn(async () => undefined),
onRunSchedule: vi.fn(async () => undefined),
onSelectTask: vi.fn(),
onSetScheduleEnabled: vi.fn(async () => undefined)
}
}
}
+18 -6
View File
@@ -8,7 +8,8 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
AssistantTask
AssistantTask,
WorkMode
} from '../../shared/assistant-contracts'
import {
findTaskSchedule,
@@ -16,6 +17,7 @@ import {
} from './TaskScheduleActions'
type ConversationTaskStripProps = {
conversationMode?: WorkMode
locale: string
onRemoveSchedule: (scheduleId: string) => Promise<void>
onRunSchedule: (scheduleId: string) => Promise<void>
@@ -30,6 +32,7 @@ type ConversationTaskStripProps = {
}
export function ConversationTaskStrip({
conversationMode,
locale,
onRemoveSchedule,
onRunSchedule,
@@ -51,6 +54,17 @@ export function ConversationTaskStrip({
const selectedSchedule = selectedTask
? findTaskSchedule(selectedTask, schedules)
: undefined
const unrunSchedule =
selectedSchedule &&
!selectedSchedule.lastRunAt &&
!selectedTask?.startedAt &&
!selectedTask?.completedAt &&
(selectedTask?.status === 'idle' ||
selectedTask?.status === 'queued' ||
selectedTask?.status === 'paused')
const selectedMode = unrunSchedule
? conversationMode
: selectedTask?.workMode
const expanded =
manuallyExpanded ||
(Boolean(selectedTaskId) && collapsedTaskId !== selectedTaskId)
@@ -136,11 +150,9 @@ export function ConversationTaskStrip({
<div>
<dt>{t('task.fields.mode')}</dt>
<dd>
{selectedSchedule
? t(`task.mode.${selectedSchedule.workMode}`)
: selectedTask.workMode
? t(`task.mode.${selectedTask.workMode}`)
: t('task.mode.unavailable')}
{selectedMode
? t(`task.mode.${selectedMode}`)
: t('task.mode.unavailable')}
</dd>
</div>
<div>
@@ -529,7 +529,7 @@ describe('DocumentParsingSettingsSection', () => {
const close = screen.getByRole('button', {
name: '关闭结果'
})
expect(close).toHaveFocus()
await waitFor(() => expect(close).toHaveFocus())
const backgroundSection = trigger.closest<HTMLElement>(
'.settings-section'
)
@@ -543,7 +543,7 @@ describe('DocumentParsingSettingsSection', () => {
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(trigger).toHaveFocus()
await waitFor(() => expect(trigger).toHaveFocus())
expect(backgroundSection?.inert).toBe(false)
expect(test).toHaveBeenCalledWith('chat-attachment')
expect(update).not.toHaveBeenCalled()
@@ -1,4 +1,5 @@
import {
act,
cleanup,
fireEvent,
render,
@@ -7,6 +8,9 @@ import {
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
import { workbarLayoutPreferencesSchema } from '../../shared/workbar-contracts'
import { DEFAULT_WORKBAR_INSTANCES } from './WorkbarShell'
import type {
AssistantProject,
AssistantTask,
@@ -26,18 +30,39 @@ import {
type SidebarArtifact
} from './RightAssistantSidebar'
vi.mock('./TerminalPanel', () => ({
TerminalPanel: ({ onSessionChange }: {
onSessionChange?: (snapshot: TerminalSnapshot) => void
}) => (
<button
onClick={() => onSessionChange?.({
sessionId: '00000000-0000-4000-8000-000000000498',
state: 'running'
} as TerminalSnapshot)}
type="button"
>
Start test terminal
</button>
)
}))
afterEach(cleanup)
const firstBrowserTabId =
'00000000-0000-4000-8000-000000000401' as BrowserTabId
const secondBrowserTabId =
'00000000-0000-4000-8000-000000000402' as BrowserTabId
const browserStateListeners = new Set<(state: BrowserLiveState) => void>()
const browserApi = {
createTab: vi.fn<(request: BrowserCreateTabRequest) => Promise<BrowserTabSummary>>(),
closeTab: vi.fn(async () => undefined),
setViewport: vi.fn<
(request: BrowserSetViewportRequest) => Promise<void>
>(async () => undefined)
>(async () => undefined),
onState: (listener: (state: BrowserLiveState) => void) => {
browserStateListeners.add(listener)
return () => { browserStateListeners.delete(listener) }
}
}
function browserSummary(
@@ -59,6 +84,7 @@ function browserSummary(
beforeEach(() => {
localStorage.clear()
browserStateListeners.clear()
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1400
@@ -115,7 +141,8 @@ function sidebarElement({
onBackBrowser,
onNavigateBrowser,
onReloadBrowser,
onStopLoadingBrowser
onStopLoadingBrowser,
onTabChange = vi.fn()
}: {
tab?: AssistantSidebarTab
approvals?: PendingSidebarApproval[]
@@ -146,6 +173,7 @@ function sidebarElement({
onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onTabChange?: (tab: AssistantSidebarTab) => void
} = {}): React.JSX.Element {
return (
<div>
@@ -177,7 +205,7 @@ function sidebarElement({
onSetScheduleEnabled={vi.fn(async () => undefined)}
onStopLoadingBrowser={onStopLoadingBrowser}
onOpenTask={vi.fn()}
onTabChange={vi.fn()}
onTabChange={onTabChange}
open
restoreFocusRef={restoreFocusRef}
tab={tab}
@@ -1177,4 +1205,166 @@ describe('RightAssistantSidebar resizing', () => {
screen.getByRole('tab', { name: '浏览器 · conversation-a' })
).toBeInTheDocument()
})
it('rebinds a released tab before retrying navigation and can close a released instance', async () => {
const conversationId = 'retry-browser'
const onNavigateBrowser = vi.fn(async () => undefined)
renderSidebar({ tab: 'browser', activeConversationId: conversationId, onNavigateBrowser })
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
const owner = browserApi.createTab.mock.calls[0]![0].workbarInstanceId
const stopped: BrowserLiveState = {
...browserSummary(conversationId, firstBrowserTabId, true),
status: 'stopped',
sessionActive: false
}
act(() => {
for (const listener of browserStateListeners) listener(stopped)
})
browserApi.createTab.mockResolvedValueOnce(browserSummary(conversationId, secondBrowserTabId, true))
fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), {
target: { value: 'https://retry.example/' }
})
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() => expect(onNavigateBrowser).toHaveBeenCalledWith(
conversationId, secondBrowserTabId, 'https://retry.example/'
))
expect(browserApi.createTab).toHaveBeenLastCalledWith({ conversationId, workbarInstanceId: owner })
act(() => {
for (const listener of browserStateListeners) listener({ ...stopped, tabId: secondBrowserTabId })
})
fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · retry-browser' }))
await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 · retry-browser' })).not.toBeInTheDocument())
expect(browserApi.closeTab).not.toHaveBeenCalled()
})
it('shows the request-created Main tab without creating a second blank page', async () => {
const conversationId = 'request-browser'
const workbarInstanceId = '00000000-0000-4000-8000-000000000499'
const onNavigateBrowser = vi.fn(async () => undefined)
renderSidebar({
tab: 'browser',
activeConversationId: conversationId,
onNavigateBrowser,
browserStates: {
[conversationId]: {
[firstBrowserTabId]: {
...browserSummary(conversationId, firstBrowserTabId, true),
workbarInstanceId,
sessionActive: true,
url: 'https://agent.example/'
}
}
}
})
await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://agent.example/'))
expect(browserApi.createTab).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '前往' }))
await waitFor(() => expect(onNavigateBrowser).toHaveBeenCalledWith(
conversationId, firstBrowserTabId, 'https://agent.example/'
))
expect(browserApi.createTab).not.toHaveBeenCalled()
})
it('retains a request page at layout capacity and binds it after a slot is closed', async () => {
const conversationId = 'full-workbar'
const instances = [
...DEFAULT_WORKBAR_INSTANCES.filter((instance) => instance.appId === 'tasks' || instance.appId === 'workspace'),
...Array.from({ length: 30 }, (_, index) => ({
id: crypto.randomUUID(),
appId: 'browser' as const,
title: `parked-${index}`,
targetRef: { type: 'conversation' as const, conversationId }
}))
]
localStorage.setItem('goodbuddy.workbar-layout.v1', JSON.stringify({
instances, activeInstanceId: instances[0]!.id,
expanded: true, dock: 'right', widthRatio: 0.3,
taskScope: 'current-project'
}))
const workbarInstanceId = '00000000-0000-4000-8000-000000000497'
renderSidebar({
tab: 'browser', activeConversationId: conversationId,
browserStates: {
[conversationId]: {
[firstBrowserTabId]: {
...browserSummary(conversationId, firstBrowserTabId, true),
workbarInstanceId, sessionActive: true, url: 'https://retained.example/'
}
}
}
})
expect(screen.getByRole('alert')).toHaveTextContent('请关闭一个可关闭页签')
expect(screen.getAllByRole('tab')).toHaveLength(32)
expect(browserApi.createTab).not.toHaveBeenCalled()
expect(workbarLayoutPreferencesSchema.safeParse(
JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!)
).success).toBe(true)
fireEvent.click(screen.getByRole('button', { name: '关闭parked-0' }))
await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://retained.example/'))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getAllByRole('tab')).toHaveLength(32)
expect(browserApi.createTab).not.toHaveBeenCalled()
const restored = workbarLayoutPreferencesSchema.parse(
JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!)
)
expect(restored.instances.some((instance) => instance.id === workbarInstanceId)).toBe(true)
expect(restored.activeInstanceId).toBe(workbarInstanceId)
})
it('preserves an explicit cross-conversation tab selection when the external app follows it', async () => {
const onTabChange = vi.fn()
const view = render(sidebarElement({
tab: 'browser', activeConversationId: 'conversation-a', onTabChange
}))
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce())
view.rerender(sidebarElement({
tab: 'browser', activeConversationId: 'conversation-b', onTabChange
}))
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!)
await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByRole('tab', { name: '任务中心' }))
view.rerender(sidebarElement({
tab: 'tasks', activeConversationId: 'conversation-b', onTabChange
}))
const browserA = screen.getByRole('tab', { name: '浏览器 · conversation-a' })
fireEvent.click(browserA)
expect(onTabChange).toHaveBeenLastCalledWith('browser')
view.rerender(sidebarElement({
tab: 'browser', activeConversationId: 'conversation-b', onTabChange
}))
expect(browserA).toHaveAttribute('aria-selected', 'true')
})
it('releases the native browser viewport while confirming another terminal close', async () => {
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({ tab: 'browser', activeConversationId: 'modal-browser' })
await waitFor(() => expect(browserApi.setViewport).toHaveBeenCalledWith(
expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) })
))
fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' }))
fireEvent.click(screen.getByText('终端', { selector: 'strong' }).closest('button')!)
fireEvent.click(await screen.findByRole('button', { name: 'Start test terminal' }))
fireEvent.click(screen.getByRole('tab', { name: '浏览器 · modal-browser' }))
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) })
))
const activeViewport = browserApi.setViewport.mock.calls.at(-1)![0]
fireEvent.click(screen.getByRole('button', { name: '关闭终端 1' }))
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({
leaseToken: activeViewport.leaseToken
}))
const dialog = screen.getByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: /取消/u }))
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) })
))
} finally {
bounds.mockRestore()
}
})
})
+84 -2
View File
@@ -1021,6 +1021,7 @@ export function RightAssistantSidebar({
if (instance.appId === 'browser') {
browserMruRef.current[instance.id] = ++nextBrowserMruRef.current
}
lastExternalTabRef.current = instance.appId
onTabChange(instance.appId)
}
},
@@ -1160,6 +1161,71 @@ export function RightAssistantSidebar({
[activeConversationId, conversationTitles, t]
)
useEffect(() => window.goodbuddy.browser?.onState?.((state) => {
if (state.status !== 'stopped') return
const next = { ...browserTabIdsRef.current }
let changed = false
for (const [instanceId, tabId] of Object.entries(next)) {
if (tabId === state.tabId) {
delete next[instanceId]
changed = true
}
}
if (changed) {
browserTabIdsRef.current = next
setBrowserTabIds(next)
}
}), [])
useEffect(() => {
const liveTabs = Object.values(browserStates ?? {}).flatMap((states) =>
Object.values(states).filter((state) =>
state.workbarInstanceId &&
(state.sessionActive || state.status === 'creating')
)
)
const nextBindings = { ...browserTabIdsRef.current }
let changed = false
const newInstances: WorkbarTabInstance[] = []
for (const state of liveTabs) {
const instanceId = state.workbarInstanceId!
if (nextBindings[instanceId] !== state.tabId) {
nextBindings[instanceId] = state.tabId
changed = true
}
if (!workbarInstances.some((instance) => instance.id === instanceId)) {
newInstances.push({
id: instanceId,
appId: 'browser',
title: `${t('sidebar.tabs.browser.label')} · ${conversationTitles.get(state.conversationId) ?? state.conversationId}`,
targetRef: { type: 'conversation', conversationId: state.conversationId }
})
}
}
if (changed) {
browserTabIdsRef.current = nextBindings
setBrowserTabIds(nextBindings)
}
const additions = newInstances.slice(
0, Math.max(0, WORKBAR_LIMITS.maximumOpenInstances - workbarInstances.length)
)
if (additions.length > 0) {
setWorkbarInstances((current) => [
...current,
...additions
.filter((candidate) => !current.some((instance) => instance.id === candidate.id))
.slice(0, Math.max(0, WORKBAR_LIMITS.maximumOpenInstances - current.length))
])
const requested = additions.find((instance) =>
instance.targetRef?.type === 'conversation' &&
instance.targetRef.conversationId === activeConversationId
)
if (requested && tab === 'browser') {
setActiveWorkbarInstanceId(requested.id)
}
}
}, [activeConversationId, browserStates, conversationTitles, tab, t, workbarInstances])
const removeWorkbarInstance = useCallback(
(instanceId: string): void => {
setWorkbarInstances((current) =>
@@ -1567,10 +1633,20 @@ export function RightAssistantSidebar({
}
}
const hasUnboundRequestBrowser = Object.values(
browserStates?.[activeConversationId ?? ''] ?? {}
).some((state) =>
state.workbarInstanceId &&
(state.sessionActive || state.status === 'creating') &&
!workbarInstances.some((instance) => instance.id === state.workbarInstanceId)
)
useEffect(() => {
if (!open || activeWorkbarInstance?.appId !== 'browser') {
return
}
// Bind a newly materialized request tab before opening a fallback instance.
if (hasUnboundRequestBrowser) return
void ensureBrowserTab(activeWorkbarInstance).catch((reason: unknown) => {
setActionError(
reason instanceof Error
@@ -1578,7 +1654,7 @@ export function RightAssistantSidebar({
: t('sidebar.errors.browserControlUnavailable')
)
})
}, [activeWorkbarInstance, ensureBrowserTab, open, t])
}, [activeWorkbarInstance, ensureBrowserTab, hasUnboundRequestBrowser, open, t])
return (
<aside
@@ -1676,6 +1752,12 @@ export function RightAssistantSidebar({
<div
className={`assistant-sidebar__body${instance.appId === 'browser' ? ' assistant-sidebar__body--browser' : ''}`}
>
{hasUnboundRequestBrowser &&
workbarInstances.length >= WORKBAR_LIMITS.maximumOpenInstances ? (
<p className="settings-error" role="alert">
{t('sidebar.browser.workbarFull', { count: WORKBAR_LIMITS.maximumOpenInstances })}
</p>
) : null}
{actionError ? (
<p className="settings-error" role="alert">
{actionError}
@@ -2296,7 +2378,7 @@ export function RightAssistantSidebar({
: undefined
}
tabId={browserTabIds[instance.id]}
visible={open && instance.id === activeWorkbarInstanceId}
visible={open && instance.id === activeWorkbarInstanceId && !terminalCloseConfirmation}
/>
</section>
)}
+144 -6
View File
@@ -150,6 +150,7 @@ const runtimeSettings: RuntimeSettings = {
profileId: modelProfileId
},
deepseekHarnessModelSource: { kind: 'platform' },
deepseekHarnessPlatformModel: { source: 'unavailable' },
secureStorageAvailable: true,
toolApproval: 'always'
}
@@ -3085,7 +3086,9 @@ describe('SettingsPanel runtime files', () => {
)
})
it('keeps an environment-managed source compatible without exposing it as an option', async () => {
it.each(['environment', 'profile'] as const)(
'uses the Main-projected Harness %s source instead of guessing from environment credentials',
async (sourceKind) => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelBaseUrl: 'https://gateway.example/openai/v1',
@@ -3101,6 +3104,9 @@ describe('SettingsPanel runtime files', () => {
}
],
deepseekHarnessModelSource: { kind: 'platform' },
deepseekHarnessPlatformModel: sourceKind === 'environment'
? { source: 'environment', name: '管理员预置模型', modelName: 'actual-administrator-model' }
: { source: 'profile', profileId: modelProfileId, name: '默认模型', modelName: 'qwen-plus' },
configured: {
modelProfiles: [
{
@@ -3138,15 +3144,26 @@ describe('SettingsPanel runtime files', () => {
})
)
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
expect(
screen.getByText(
'自动选择:优先使用管理员预置连接,否则跟随当前兼容模型'
)
).toBeInTheDocument()
if (sourceKind === 'environment') {
expect(screen.getByText('此选项实际使用管理员预置:actual-administrator-model。')).toBeInTheDocument()
expect(screen.queryByText(/已保存配置中的实际回退/)).not.toBeInTheDocument()
} else {
expect(screen.getByText(/未配置完整的管理员预置;已保存配置中的实际回退:默认模型 · qwen-plus。/)).toBeInTheDocument()
expect(screen.queryByText(/此选项实际使用管理员预置/)).not.toBeInTheDocument()
}
const source = screen.getByLabelText(
'DeepSeek Harness OpenAI 兼容模型连接'
)
expect(source).toHaveValue('platform')
expect(screen.queryByText(/当前环境生效模型/)).not.toBeInTheDocument()
expect(
within(source).getByRole('option', {
name: '管理员环境优先,回退 GoodBuddy 兼容连接'
})
).toHaveValue('platform')
expect(
within(source).queryByRole('option', { name: /自有配置/ })
).not.toBeInTheDocument()
fireEvent.change(source, {
target: { value: runtimeSettings.modelProfiles[0]!.id }
})
@@ -3161,6 +3178,127 @@ describe('SettingsPanel runtime files', () => {
)
})
it.each([
['zh-CN', true],
['zh-CN', false],
['en-US', true],
['en-US', false]
] as const)('shows the saved Harness fallback in %s (compatible default: %s)', async (locale, compatibleDefault) => {
await changeUiLocale(locale)
const firstCompatible = {
...runtimeSettings.modelProfiles[0]!,
id: '00000000-0000-4000-8000-000000000052',
name: 'First compatible',
modelName: 'first-model',
protocol: 'openai-chat-completions' as const
}
const preferred = {
...firstCompatible,
id: modelProfileId,
name: 'Preferred',
modelName: 'preferred-model',
protocol: compatibleDefault
? 'openai-chat-completions' as const
: 'openai-images-generations' as const
}
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [firstCompatible, preferred],
deepseekHarnessModelSource: { kind: 'platform' },
deepseekHarnessPlatformModel: {
source: 'profile',
profileId: compatibleDefault ? preferred.id : firstCompatible.id,
name: compatibleDefault ? preferred.name : firstCompatible.name,
modelName: compatibleDefault ? preferred.modelName : firstCompatible.modelName
}
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(await screen.findByRole('button', {
name: locale === 'zh-CN' ? 'DeepSeek Harness(预览)' : 'DeepSeek Harness (Preview)'
}))
const source = screen.getByLabelText(locale === 'zh-CN'
? 'DeepSeek Harness OpenAI 兼容模型连接'
: 'DeepSeek Harness OpenAI-compatible model connection')
expect(source).toHaveValue('platform')
expect(within(source).getByRole('option', {
name: locale === 'zh-CN'
? '管理员环境优先,回退 GoodBuddy 兼容连接'
: 'Administrator environment, then GoodBuddy fallback'
})).not.toBeDisabled()
const resolvedName = compatibleDefault ? 'Preferred · preferred-model' : 'First compatible · first-model'
expect(screen.getByText(locale === 'zh-CN'
? `未配置完整的管理员预置;已保存配置中的实际回退:${resolvedName}。`
: `No complete administrator preset; actual fallback in saved settings: ${resolvedName}.`)).toBeInTheDocument()
expect(source).toHaveAccessibleDescription(/Harness/)
})
it.each([true, false])('keeps the Harness platform option usable without compatible saved profiles (administrator available: %s)', async (available) => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings, deepseekHarnessModelSource: { kind: 'default' },
deepseekHarnessPlatformModel: available
? { source: 'environment', name: '管理员预置模型', modelName: 'administrator-model' }
: { source: 'unavailable' }
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek Harness(预览)' }))
const source = screen.getByLabelText('DeepSeek Harness OpenAI 兼容模型连接')
expect(source).not.toBeDisabled()
expect(within(source).getByRole('option', { name: '跟随全局默认模型' })).toBeDisabled()
fireEvent.change(source, { target: { value: 'platform' } })
expect(source).toHaveValue('platform')
expect(screen.getByText(available
? '此选项实际使用管理员预置:administrator-model。'
: '没有可用的管理员预置或兼容的 GoodBuddy 连接。')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({ deepseekHarnessModelSource: { kind: 'platform' } })
))
})
it('keeps the actual saved Harness fallback separate from the unsaved draft preview', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [{
...runtimeSettings.modelProfiles[0]!,
protocol: 'openai-chat-completions', modelName: 'saved-model'
}],
deepseekHarnessPlatformModel: {
source: 'profile', profileId: modelProfileId, name: '默认模型', modelName: 'saved-model'
}
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()} onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.change(screen.getByLabelText('名称'), { target: { value: 'Draft connection' } })
fireEvent.click(screen.getByRole('tab', { name: 'Agent Runtime' }))
fireEvent.click(await screen.findByRole('button', { name: 'DeepSeek Harness(预览)' }))
expect(screen.getByText(/已保存配置中的实际回退:默认模型 · saved-model。/)).toBeInTheDocument()
expect(screen.getByText(/草稿兼容候选(未保存):Draft connection · saved-model。/)).toBeInTheDocument()
expect(updateRuntime).not.toHaveBeenCalled()
})
it('selects, warns about, clears, and saves a custom binary', async () => {
render(
<SettingsPanel
+39 -5
View File
@@ -1927,6 +1927,40 @@ export function SettingsPanel({
defaultModelProfileId,
isDeepseekHarnessCompatible
)
const harnessPlatformModel = settings?.deepseekHarnessPlatformModel
const harnessDraftFallbackChanged =
harnessPlatformModel &&
harnessPlatformModel.source !== 'environment' &&
(harnessPlatformModel.source === 'profile'
? harnessPlatformModel.profileId !== defaultDeepseekHarnessModelProfile?.id ||
harnessPlatformModel.name !== defaultDeepseekHarnessModelProfile?.name ||
harnessPlatformModel.modelName !== defaultDeepseekHarnessModelProfile?.modelName
: Boolean(defaultDeepseekHarnessModelProfile))
const harnessPlatformSummary = [
!harnessPlatformModel
? t('runtime.deepseekHarness.platformSourceUnavailable')
: harnessPlatformModel.source === 'environment'
? t('runtime.deepseekHarness.environmentSource', {
model: harnessPlatformModel.modelName
})
: harnessPlatformModel.source === 'profile'
? t('runtime.deepseekHarness.platformFallback', {
name: modelProfileDisplayName({
id: harnessPlatformModel.profileId,
name: harnessPlatformModel.name
}),
model: harnessPlatformModel.modelName
})
: t('runtime.deepseekHarness.platformNoFallback'),
harnessDraftFallbackChanged
? defaultDeepseekHarnessModelProfile
? t('runtime.deepseekHarness.platformDraftFallback', {
name: modelProfileDisplayName(defaultDeepseekHarnessModelProfile),
model: defaultDeepseekHarnessModelProfile.modelName
})
: t('runtime.deepseekHarness.platformDraftNoFallback')
: ''
].filter(Boolean).join(' ')
const activeRuntimeModelSource =
agentRuntimeType === 'opencode'
? opencodeModelSource
@@ -2755,7 +2789,7 @@ export function SettingsPanel({
detecting={detecting}
modelConfiguration={
activeRuntimeModelSource.kind === 'platform'
? t('runtime.deepseekHarness.managedSource')
? harnessPlatformSummary
: activeRuntimeModelProfile
? t('runtime.followGoodBuddy', {
name: modelProfileDisplayName(
@@ -2778,7 +2812,7 @@ export function SettingsPanel({
aria-label={`DeepSeek Harness ${t(
'runtime.deepseekHarness.connection'
)}`}
disabled={!defaultDeepseekHarnessModelProfile}
aria-describedby="deepseek-harness-connection-description"
onChange={(event) =>
setDeepseekHarnessModelSource(
parseModelSource(event.target.value)
@@ -2790,8 +2824,8 @@ export function SettingsPanel({
: deepseekHarnessModelSource.kind
}
>
<option value="default">{t('runtime.followRecommended')}</option>
<option value="platform">{t('runtime.ownConfiguration', { runtime: 'DeepSeek Harness' })}</option>
<option disabled={!defaultDeepseekHarnessModelProfile} value="default">{t('runtime.followRecommended')}</option>
<option value="platform">{t('runtime.deepseekHarness.platformSource')}</option>
{modelProfiles.map((profile) => (
<option
disabled={!isDeepseekHarnessCompatible(profile)}
@@ -2805,7 +2839,7 @@ export function SettingsPanel({
</option>
))}
</select>
<small>
<small id="deepseek-harness-connection-description">
{t(
'runtime.deepseekHarness.connectionDescription'
)}
@@ -4,6 +4,7 @@ import { changeUiLocale } from './i18n'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { gitStatusLetter, WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import type { WorkspaceManagementAction, WorkspaceManagementResult } from '../../shared/workspace-management-contracts'
beforeEach(() => vi.stubGlobal('goodbuddy', { workspace: { manage: vi.fn(async () => ({ kind: 'branches', current: 'main', branches: [] })) } }))
@@ -14,6 +15,78 @@ afterEach(async () => {
})
describe('WorkspaceFilesPanel', () => {
it.each([false, true])('keeps Git operations usable when a task refresh occurs during history pagination (failure: %s)', async (fail) => {
const head = 'a'.repeat(40)
const initial = { oid: head, subject: 'Latest commit', author: 'Author', time: '2026-09-09T10:00:00Z', refs: 'HEAD -> main' }
let resolvePage!: (value: WorkspaceManagementResult) => void
let rejectPage!: (reason: Error) => void
const page = new Promise<WorkspaceManagementResult>((resolve, reject) => { resolvePage = resolve; rejectPage = reject })
const manage = vi.fn(async (_project: string, action: WorkspaceManagementAction): Promise<WorkspaceManagementResult> => {
if (action.kind === 'history') return action.offset
? page
: { kind: 'history', head, commits: [initial], hasMore: true }
if (action.kind === 'fetch') return { kind: 'done' }
return { kind: 'branches', current: 'main', branches: [] }
})
vi.stubGlobal('goodbuddy', { workspace: { manage } })
const props = {
projectId: 'project', isRepository: true, changedFiles: [],
onListDirectory: vi.fn(async () => ({ path: '', entries: [], truncated: false })),
onLoadDiff: vi.fn(), onOpenFile: vi.fn(), onRefresh: vi.fn(async () => undefined)
}
const { rerender } = render(<WorkspaceFilesPanel {...props} refreshToken={0} />)
await waitFor(() => expect(props.onListDirectory).toHaveBeenCalledOnce())
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
fireEvent.click(screen.getByRole('button', { name: '提交历史' }))
fireEvent.click(await screen.findByRole('button', { name: '加载更多提交' }))
expect(screen.getByRole('button', { name: 'Fetch' })).toBeDisabled()
expect(manage).toHaveBeenCalledWith('project', { kind: 'history', offset: 1, head })
rerender(<WorkspaceFilesPanel {...props} refreshToken={1} />)
await waitFor(() => expect(manage.mock.calls.filter(([, action]) => action.kind === 'branches')).toHaveLength(2))
await act(async () => {
if (fail) rejectPage(new Error('History temporarily unavailable'))
else resolvePage({ kind: 'history', head, commits: [{ ...initial, oid: 'b'.repeat(40), subject: 'Older commit' }], hasMore: false })
})
if (fail) {
expect(screen.getByRole('alert')).toHaveTextContent('History temporarily unavailable')
expect(screen.getByRole('button', { name: '重试' })).toBeEnabled()
} else expect(screen.getByText('Older commit')).toBeInTheDocument()
expect(screen.getByText('Latest commit')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Fetch' })).toBeEnabled()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Fetch' }))
await waitFor(() => expect(props.onRefresh).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByRole('button', { name: 'Fetch' })).toBeEnabled())
expect(manage).toHaveBeenCalledWith('project', { kind: 'fetch' })
})
it.each([false, true])('refreshes the browsed directory once even when its tree entry is not expanded (expanded: %s)', async (expanded) => {
let version = 0
const onListDirectory = vi.fn(async (path: string) => ({
path, truncated: false, entries: path
? [{ name: `file-${version}.txt`, path: `${path}/file-${version}.txt`, type: 'file' as const }]
: [{ name: 'docs', path: 'docs', type: 'directory' as const }]
}))
const props = { projectId: 'project', changedFiles: [], onListDirectory, onLoadDiff: vi.fn(), onOpenFile: vi.fn() }
const { rerender } = render(<WorkspaceFilesPanel {...props} refreshToken={version} />)
const directory = await screen.findByRole('button', { name: 'docs' })
if (expanded) {
fireEvent.click(directory)
await screen.findByRole('button', { name: 'file-0.txt' })
} else expect(directory).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(screen.getByRole('button', { name: 'docs 的更多操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '浏览此目录' }))
await screen.findByRole('button', { name: 'file-0.txt' })
expect(screen.getByRole('button', { name: 'docs' })).toHaveAttribute('aria-current', 'location')
const beforeRefresh = onListDirectory.mock.calls.length
version++
rerender(<WorkspaceFilesPanel {...props} refreshToken={version} />)
await screen.findByRole('button', { name: 'file-1.txt' })
expect(screen.queryByRole('button', { name: 'file-0.txt' })).not.toBeInTheDocument()
expect(onListDirectory.mock.calls.slice(beforeRefresh)).toEqual([[''], ['docs']])
expect(screen.getByRole('button', { name: 'docs' })).toHaveAttribute('aria-current', 'location')
})
it('keeps directory navigation and compact actions bounded independently', () => {
const css = readFileSync(join(process.cwd(), 'src/renderer/src/styles.css'), 'utf8')
expect(css.match(/\.workspace-files__actions\s*\{([^}]*)\}/)?.[1]).toContain('flex-wrap: nowrap')
+2 -3
View File
@@ -254,9 +254,8 @@ export function WorkspaceFilesPanel({
)
const refreshDirectories = useEffectEvent((generation: number) => {
const paths = expandedState.projectId === projectId ? [...expandedState.value] : []
const paths = new Set(['', browsedPath, ...(expandedState.projectId === projectId ? expandedState.value : [])])
setLoadingState({ projectId, value: new Set() })
void loadDirectory('', generation)
for (const path of paths) void loadDirectory(path, generation)
if (diff) openDiff(diff.path)
})
@@ -505,7 +504,7 @@ export function WorkspaceFilesPanel({
{ value: 'changes', label: t('management.gitWorkspace') }
]} /></div>}
<div hidden={activeView !== 'changes'}>
{showGit && <WorkspaceGitTools projectId={projectId} refreshToken={refreshToken} onRefresh={refresh} viewControl={
{showGit && <WorkspaceGitTools key={projectId} projectId={projectId} refreshToken={refreshToken} onRefresh={refresh} viewControl={
<SegmentedControl ariaLabel={t('management.changedView')} value={changeView} onChange={setChangeView} options={[{ value: 'list', label: t('management.list') }, { value: 'tree', label: t('management.tree') }]} />
}>
{gitError ? <p className="workspace-files__status" role="status">{t('sidebar.workspace.gitUnavailable', { error: gitError })}</p>
+5 -2
View File
@@ -30,13 +30,16 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
const commitBack = useRef<HTMLButtonElement>(null)
const action = (value: WorkspaceManagementAction): Promise<WorkspaceManagementResult> => window.goodbuddy.workspace.manage(projectId, value)
useEffect(() => {
const branchCounter = branchRequest
const detailCounter = request
return () => { detailCounter.current++ }
}, [])
useEffect(() => {
const branchCounter = branchRequest
const generation = ++branchRequest.current
void window.goodbuddy.workspace.manage(projectId, { kind: 'branches' }).then((result) => {
if (branchRequest.current === generation && result.kind === 'branches') setBranches(result)
}).catch((reason: unknown) => { if (branchRequest.current === generation) setError(String(reason)) })
return () => { branchCounter.current++; detailCounter.current++ }
return () => { branchCounter.current++ }
}, [projectId, refreshToken])
useEffect(() => { if (path) diffBack.current?.focus(); else if (commit) commitBack.current?.focus() }, [path, commit])
const run = async (value: WorkspaceManagementAction): Promise<void> => {
@@ -399,13 +399,24 @@ export const settings = {
previewDescription: 'Developer preview · OpenAI-compatible',
description:
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Ask can call native read/skill plus enabled Web Search/Fetch, while Execute can use every enabled tool and DSH plugin capability. Cancellation and workspace boundaries remain in place.',
managedSource:
'Automatic: prefer the administrator connection, otherwise follow a compatible current model',
platformSource: 'Administrator environment, then GoodBuddy fallback',
environmentSource:
'This option resolves to the administrator preset: {{model}}.',
platformFallback:
'No complete administrator preset; actual fallback in saved settings: {{name}} · {{model}}.',
platformNoFallback:
'No available administrator preset or compatible GoodBuddy connection.',
platformSourceUnavailable:
'Model source information is unavailable. Reload settings.',
platformDraftFallback:
'Draft compatible candidate (not saved): {{name}} · {{model}}. Saving resolves the source again against the administrator environment.',
platformDraftNoFallback:
'The unsaved draft has no compatible fallback connection.',
connection: 'OpenAI-compatible model connection',
connectionPlaceholder:
'Automatically select a compatible model connection',
connectionDescription:
'Optionally select a GoodBuddy model connection. Otherwise GoodBuddy uses the administrator connection, the compatible current default, or the first compatible connection. It must use OpenAI Chat Completions with API-key authentication.',
'Administrator environment first uses the deployment’s complete model URL, model name, and API key; otherwise it uses the compatible GoodBuddy default, then the first compatible connection. Follow the global default model skips the administrator preset; a named connection stays pinned. Connections must use OpenAI Chat Completions with API-key authentication. This does not load Harness user profiles or configuration files.',
advancedDescription:
'This Runtime always uses GoodBuddy’s bundled, version-pinned Host and does not load user profiles or custom Hosts. GoodBuddy manages enabled marketplace plugins and loads them with the Host.',
marketplace: {
@@ -374,6 +374,7 @@ export const workspace = {
browser: {
title: 'Live browser',
unbound: 'No conversation',
workbarFull: 'The workbar has {{count}} open tabs. The Agent page is retained; close a closable tab to view it.',
interact: 'Interact',
interacting: 'Interacting',
close: 'Close browser',
@@ -367,12 +367,23 @@ export const settings = {
previewDescription: '开发者预览 · OpenAI 兼容',
description:
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Ask 可调用 Harness 原生 read/skill 与已启用的网页搜索/抓取,Execute 可调用全部已启用工具及 DSH 插件能力,并保留取消和工作区边界。',
managedSource:
'自动选择:优先使用管理员预置连接,否则跟随当前兼容模型',
platformSource: '管理员环境优先,回退 GoodBuddy 兼容连接',
environmentSource:
'此选项实际使用管理员预置:{{model}}。',
platformFallback:
'未配置完整的管理员预置;已保存配置中的实际回退:{{name}} · {{model}}。',
platformNoFallback:
'没有可用的管理员预置或兼容的 GoodBuddy 连接。',
platformSourceUnavailable:
'模型来源信息不可用,请重新加载设置。',
platformDraftFallback:
'草稿兼容候选(未保存):{{name}} · {{model}}。保存后按管理员环境重新解析来源。',
platformDraftNoFallback:
'未保存草稿中没有兼容回退连接。',
connection: 'OpenAI 兼容模型连接',
connectionPlaceholder: '自动选择兼容模型连接',
connectionDescription:
'可从 GoodBuddy 模型连接中显式指定;未指定时自动使用管理员预置连接、当前默认兼容连接或首个兼容连接。协议必须为 OpenAI Chat Completions,并使用 API Key。',
'“管理员环境优先”先使用部署环境中完整的模型地址、模型名称和 API Key,否则使用 GoodBuddy 默认兼容连接,再回退首个兼容连接。“跟随全局默认模型”跳过管理员预置;指定连接则保持固定。连接必须使用 OpenAI Chat Completions 和 API Key。此选项不加载 Harness 用户 profile 或配置文件。',
advancedDescription:
'该 Runtime 始终使用 GoodBuddy 内置并固定版本的 Host,不加载用户 profile 或自定义 Host;已启用的市场插件由 GoodBuddy 托管并随 Host 启动。',
marketplace: {
@@ -363,6 +363,7 @@ export const workspace = {
browser: {
title: '实时浏览器',
unbound: '未绑定会话',
workbarFull: '工作栏已打开 {{count}} 个页签。Agent 页面已保留,请关闭一个可关闭页签后查看。',
interact: '交互',
interacting: '交互中',
close: '关闭浏览器',
+20
View File
@@ -55,6 +55,26 @@ describe('browser control contracts', () => {
).toBe(false)
})
it('carries workbar ownership without the obsolete frame payload', () => {
const state = {
conversationId: 'conversation',
tabId,
workbarInstanceId,
status: 'creating',
sessionActive: false,
isLoading: true,
canGoBack: false,
updatedAt: 1
}
expect(browserLiveStateSchema.parse(state)).toEqual(state)
expect(browserLiveStateSchema.safeParse({
...state, workbarInstanceId: 'not-a-uuid'
}).success).toBe(false)
expect(browserLiveStateSchema.safeParse({
...state, frameDataUrl: 'data:image/jpeg;base64,obsolete'
}).success).toBe(false)
})
it('keeps browser navigation and stop-loading requests narrow', () => {
expect(
browserNavigateRequestSchema.parse({
+8 -8
View File
@@ -1113,6 +1113,12 @@ export type RuntimeSettings = {
continueModelSource: RuntimeModelSource
deepseekHarnessModelSource?: RuntimeModelSource
secureStorageAvailable: boolean
/** Read-only resolution of the platform option against the saved settings. Never persisted. */
readonly deepseekHarnessPlatformModel?: Readonly<
| { source: 'environment'; name: string; modelName: string }
| { source: 'profile'; profileId: string; name: string; modelName: string }
| { source: 'unavailable' }
>
toolApproval: RuntimeSettingsInput['toolApproval']
configured?: ConfiguredRuntimeSettings
warnings?: SettingsWarning[]
@@ -1383,20 +1389,13 @@ export const browserLiveStateSchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema,
workbarInstanceId: browserWorkbarInstanceIdSchema.optional(),
ownerWindowId: z.number().int().nonnegative().optional(),
status: browserStatusSchema,
sessionActive: z.boolean(),
isLoading: z.boolean(),
canGoBack: z.boolean(),
url: z.string().max(8_192).optional(),
frameDataUrl: z
.string()
.max(400_000)
.refine(
(value) => value.startsWith('data:image/jpeg;base64,'),
'浏览器画面格式无效'
)
.optional(),
error: z.string().min(1).max(240).optional(),
updatedAt: z.number().int().nonnegative()
})
@@ -1408,6 +1407,7 @@ export const browserTabSummarySchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema,
workbarInstanceId: browserWorkbarInstanceIdSchema.optional(),
primary: z.boolean(),
status: browserStatusSchema.exclude(['stopped']),
isLoading: z.boolean(),
+6 -2
View File
@@ -174,7 +174,11 @@ export function repairChannelRuntimeSelection(
isChannelModelProfileUsable(profile)
) ??
settings.modelProfiles.find(isChannelModelProfileUsable)
const defaultDirectSelection: AgentRuntimeSelection = { provider: 'model' }
const defaultDirectSelection: AgentRuntimeSelection =
defaultDirectProfile &&
defaultDirectProfile.id !== settings.defaultModelProfileId
? { provider: 'model', profileId: defaultDirectProfile.id }
: { provider: 'model' }
if (selection.provider === 'auto') {
return defaultDirectSelection
}
@@ -204,7 +208,7 @@ export function repairChannelRuntimeSelection(
return repaired
}
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === (repaired.profileId ?? defaultDirectProfile?.id)
(candidate) => candidate.id === repaired.profileId
)
return profile && isChannelModelProfileUsable(profile)
? repaired