feat: add frosted glass appearance and improve local MCP recovery
Add an opt-in persistent frosted glass setting for the top bar and runtime checklist, with chat scrolling behind the header while preserving scroll anchors and window controls. Retry transient local MCP initialization failures once after cleanup, include redacted connection diagnostics in feedback, and index lifecycle events to reduce execution statistics scans. Release note: Add optional frosted glass appearance with chat visible behind the title bar; improve local tool connection recovery and diagnostic feedback.
This commit is contained in:
@@ -43,6 +43,10 @@
|
||||
|
||||
两种主题必须保持相同的信息层级,但不要求机械地反转明暗。浅色主题以白色主内容画布、冰蓝灰侧栏和轻微着色的顶栏建立空间关系;深色主题使用深海军蓝与蓝灰表面逐层提亮,避免纯黑。蓝色承担主要选择和交互,青绿色主要承担成功与可用状态,二者不得混用语义。
|
||||
|
||||
外观设置提供“透明磨砂特效”开关,当前默认关闭,文字在左、共享 Switch 在右。选择保存在应用设置中,保存成功后立即生效,重启后恢复。开启时,右侧内容框架顶栏的背景不透明度在浅色主题为 65%、深色主题为 70%,保留 18px 背景模糊与淡分隔线。聊天滚动视口向上延伸 58px,消息上滚时实际经过顶栏背后;不使用人工渐变充当背景。初始留白放在滚动内容中,包含顶栏高度和当前任务/待办条的实测高度,首条消息仍从这些控件下方开始。任务/待办条固定在顶栏下方并保持实色;展开的 Runtime 待办卡片使用两主题各自的 75% 不透明背景、18px 背景模糊和清晰的文字颜色。
|
||||
|
||||
已保存的开关状态同步到文档根节点,卸载时清理,使 body/top layer 中的待办卡片也能应用样式;其他菜单保持原有表面。输入区、右侧工作栏和非聊天页面保持原有位置,聊天加载态及无活动消息面板时不扩展视口。窗口拖拽和控制按钮保持原有行为;该效果不透出操作系统桌面。关闭后恢复顶栏下方的聊天布局,顶栏与待办卡片均恢复实色。`tests/frosted-glass.electron.test.ts` 使用真实 `ChatHistoryPane` 检查消息与顶栏的可见矩形交叠、初始留白、关闭恢复、浅深主题、跳底、历史加载锚点和会话切换。
|
||||
|
||||
## 3. 设计令牌
|
||||
|
||||
令牌以 CSS 自定义属性实现。`:root` 提供浅色值,`[data-theme="dark"]` 覆盖深色值。组件样式不得新增只服务于单个页面的颜色、阴影、圆角或间距常量。
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-30:本地 MCP 连接诊断与恢复
|
||||
|
||||
已实现[本地 MCP 初始化失败恢复](./runtime-process-reuse-technical-design.md#41-本地-mcp-初始化失败恢复)。
|
||||
常驻 HTTP 网关和请求级授权保留;内置及自定义 MCP 对明确的暂时性传输故障最多重试一次,
|
||||
必须先成功清理失败连接。SDK 返回错误和抛出错误均有覆盖,未知失败、HTTP/API 拒绝和
|
||||
鉴权错误不重试。界面保留脱敏错误详情,两端失败诊断通过令牌摘要关联。
|
||||
网关初始化异常也会回收未完成会话。
|
||||
|
||||
最终定向验证:`npx vitest run` 指定 `opencode-runtime`、`opencode-runtime-lifecycle`、
|
||||
`opencode-runtime-permissions`、`knowledge-mcp-gateway`、`knowledge-mcp-gateway.obsidian`、
|
||||
`create-runtime`、`desktop-diagnostics`、`feedback-service` 八个测试文件,222 项全部通过。
|
||||
`npm run typecheck` 和 `npm run lint` 通过。真实 OpenCode 与实际网关的恢复用例在连接成功后
|
||||
注入响应丢失,验证断开先于重连、授权不变、请求完成和网关继续监听;该用例每轮产生一次
|
||||
本地合成模型请求,外部模型调用为 0。另有真实 HTTP 初始化连续失败后的会话回收测试。
|
||||
|
||||
全量 `npm test` 在 600 秒后超时,未取得汇总;超时前报告 `SettingsPanel.test.tsx` 中
|
||||
中英文磨砂玻璃开关两个用例失败。这些文件存在并行修改,未判定失败归因,全量验证不记为通过。
|
||||
本次未发布或替换已安装应用。远程 Agent 的 ACP 和 Agent MCP 连接路径未修改,
|
||||
不将本地测试结果作为远程恢复验证。
|
||||
|
||||
## 2026-09-30:OpenCode 终端启动准备
|
||||
|
||||
原先每次“在终端中打开” OpenCode 都会把随包插件依赖目录(约 3,600 个文件、52 MB)
|
||||
|
||||
@@ -177,6 +177,22 @@ SelectedRuntimeManager
|
||||
健康检查也读取 owner 的总 activeRequests,不能从一个空闲 adapter 判断整个 Server
|
||||
无任务后重启。异常退出使该 owner 的全部绑定失效,明确结算受影响请求,不重放已执行工具。
|
||||
|
||||
### 4.1 本地 MCP 初始化失败恢复
|
||||
|
||||
桌面 HTTP 工具网关保持监听,MCP 注册和授权仍按请求创建、清理。本地 OpenCode 的内置
|
||||
工具和自定义 MCP 共用连接逻辑:仅对明确的连接拒绝、连接重置、断管或传输超时,在
|
||||
一秒内成功断开失败连接后重试一次。SDK 返回的无 HTTP response 网络错误和抛出的网络
|
||||
错误采用同一判定;HTTP/API 错误、鉴权或协议错误、未知失败不自动重试。
|
||||
取消立即终止后续尝试,断开失败的名称槽位保持占用。Prompt 和工具调用不重放。
|
||||
|
||||
界面显示有界、脱敏的失败详情。`runtime.mcp.failed` 诊断记录连接或断开阶段、失败类别、
|
||||
耗时、尝试次数、内置或自定义类型及可用的 HTTP 状态;通过本轮令牌的 SHA-256 摘要与
|
||||
网关鉴权、初始化和工具发现失败记录关联。日志不保存令牌、地址、请求正文或原始错误。
|
||||
网关初始化抛错时也清理未完成会话,防止失败会话累积。
|
||||
|
||||
此恢复逻辑属于本地 `OpenCodeRuntime`。远程 Agent 使用 `RuntimeAcpBackend` 和
|
||||
`AgentImageToolMcp`,不经过本地 MCP 注册或网关 HTTP 处理;本节不代表远程恢复能力。
|
||||
|
||||
## 5. DeepSeek Harness
|
||||
|
||||
### 5.1 共享 Host 的最小改法
|
||||
|
||||
@@ -129,7 +129,10 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平
|
||||
> 以及你主动添加的截图。默认不上传桌面诊断;只有勾选后才会把有界诊断摘要追加到描述。
|
||||
> 不会发送对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。
|
||||
|
||||
Checkbox 下方持续说明:诊断摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息。
|
||||
Checkbox 下方持续说明:诊断摘要含时间、组件、阶段、稳定错误码、错误类型和固定短消息,
|
||||
以及工具连接失败的类别、耗时、HTTP 状态、尝试次数、工具类型和匿名关联标识。
|
||||
MCP 附加字段由 `normalizeDesktopDiagnosticRecord` 在写入、读取和反馈导出时按白名单
|
||||
校验;关联标识为请求令牌的 SHA-256 摘要,不含原始令牌、地址或错误正文。
|
||||
Renderer 不预览、不读取也不接收诊断内容或路径。勾选后详细描述上限为 3,398 字符,为附加
|
||||
摘要预留 1,600 字符以及分隔换行;超长草稿保留在表单中并显示字段错误,不静默截断。
|
||||
|
||||
|
||||
@@ -73,6 +73,16 @@ function settings(
|
||||
}
|
||||
|
||||
describe('createAgentRuntime model compatibility', () => {
|
||||
it('passes the desktop failure observer to local OpenCode', async () => {
|
||||
const observeFailure = vi.fn()
|
||||
const runtime = createAgentRuntime(process.cwd(), settings({ provider: 'opencode' }), { observeFailure })
|
||||
try {
|
||||
expect(runtime).toMatchObject({ options: { observeFailure } })
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not treat the default model profile as the platform DeepSeek source', () => {
|
||||
const defaultProfile = {
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type ExecutionSpaceDescriptor
|
||||
} from '../execution-space'
|
||||
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
|
||||
import type { DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
|
||||
|
||||
const noModelTaskTools: ModelToolProviderLike = {
|
||||
listTools: async () => [],
|
||||
@@ -58,6 +59,7 @@ const noModelTaskTools: ModelToolProviderLike = {
|
||||
}
|
||||
|
||||
export type AgentCapabilityContext = {
|
||||
observeFailure?: DesktopDiagnosticFailureObserver
|
||||
localRuntimeRegistry?: LocalRuntimeRegistry
|
||||
skillInstructions?: string
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
@@ -306,6 +308,7 @@ export function createAgentRuntime(
|
||||
)
|
||||
}
|
||||
const options: OpenCodeRuntimeOptions = {
|
||||
observeFailure: capabilities.observeFailure,
|
||||
baseUrl,
|
||||
embedded,
|
||||
binaryPath:
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from './knowledge-mcp-gateway'
|
||||
import { browserTabIdSchema } from '../../shared/contracts'
|
||||
import { BrowserService } from '../browser/browser-service'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
|
||||
|
||||
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
|
||||
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
|
||||
@@ -178,6 +180,90 @@ function testTool(name: string): Tool {
|
||||
|
||||
const gateways: KnowledgeMcpGateway[] = []
|
||||
|
||||
it('records safe HTTP rejection phases and isolates throwing observers', async () => {
|
||||
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { throw new Error('observer failed') })
|
||||
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
|
||||
gateways.push(gateway)
|
||||
await gateway.start()
|
||||
const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal)!
|
||||
const correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}`
|
||||
const cases = [
|
||||
{ headers: {}, body: '{}', status: 401, phase: 'authentication' },
|
||||
{ headers: { authorization: `Bearer ${token}` }, body: 'private-invalid-json', status: 400, phase: 'request' },
|
||||
{ headers: { authorization: `Bearer ${token}`, 'mcp-session-id': 'private-session' }, body: '{}', status: 404, phase: 'session' },
|
||||
{ headers: { authorization: `Bearer ${token}` }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } }), status: 406, phase: 'initialize' }
|
||||
]
|
||||
for (const entry of cases) {
|
||||
const response = await fetch(gateway.getEndpoint()!, { method: 'POST', headers: { 'content-type': 'application/json', ...entry.headers }, body: entry.body })
|
||||
await response.text()
|
||||
expect(response.status).toBe(entry.status)
|
||||
expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
code: 'runtime.mcp.failed', mcp: expect.objectContaining({ phase: entry.phase, category: 'http-rejection', status: entry.status, elapsedMs: expect.any(Number) })
|
||||
}))
|
||||
}
|
||||
expect(observeFailure.mock.calls.at(-1)?.[0]).toMatchObject({ mcp: { correlationId } })
|
||||
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain(token)
|
||||
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain('private-')
|
||||
})
|
||||
|
||||
it('cleans up failed HTTP initialization and allows a subsequent connection', async () => {
|
||||
const observeFailure = vi.fn()
|
||||
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
|
||||
gateways.push(gateway)
|
||||
await gateway.start()
|
||||
const token = gateway.grant('request', [firstLibraryId], new AbortController().signal)!
|
||||
const connect = vi.spyOn(McpProtocolServer.prototype, 'connect').mockRejectedValue(new Error('private-connect-error'))
|
||||
const close = vi.spyOn(McpProtocolServer.prototype, 'close')
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const response = await fetch(gateway.getEndpoint()!, {
|
||||
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: index, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } })
|
||||
})
|
||||
await response.text()
|
||||
expect(response.status).toBe(500)
|
||||
}
|
||||
expect(close).toHaveBeenCalledTimes(9)
|
||||
expect(observeFailure).toHaveBeenCalledTimes(9)
|
||||
expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({ mcp: expect.objectContaining({ phase: 'initialize', category: 'handler-failure', status: 500 }) }))
|
||||
connect.mockRestore()
|
||||
const client = new Client({ name: 'test', version: '1' })
|
||||
try {
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } }))
|
||||
await client.listTools()
|
||||
await client.callTool({ name: 'knowledge_list', arguments: {} })
|
||||
expect(observeFailure).toHaveBeenCalledTimes(9)
|
||||
} finally { await client.close() }
|
||||
})
|
||||
|
||||
it('persists asynchronous HTTP tool discovery failures without private error details', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'gateway-diagnostics-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const diagnostics = new DesktopDiagnostics(directory)
|
||||
const pending: Promise<void>[] = []
|
||||
let clock = 100
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => clock)
|
||||
const gateway = new KnowledgeMcpGateway(createService().service, {
|
||||
observeFailure: failure => { pending.push(diagnostics.recordFailure(failure)) },
|
||||
storyGraphService: { available: async () => { clock = 145; throw new Error('private-url-token-body') }, read: () => ({}) }
|
||||
})
|
||||
gateways.push(gateway)
|
||||
await gateway.start()
|
||||
const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal, 'none', undefined, undefined, undefined, undefined, undefined, { runtimeTarget: 'opencode' })!
|
||||
const client = new Client({ name: 'test', version: '1' })
|
||||
try {
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } }))
|
||||
await expect(client.listTools()).rejects.toThrow()
|
||||
await Promise.all(pending)
|
||||
const exported = (await diagnostics.exportRecent()).toString('utf8')
|
||||
expect(JSON.parse(exported)).toMatchObject({ code: 'runtime.mcp.failed', mcp: { phase: 'tool-discovery', category: 'handler-failure', elapsedMs: 45 } })
|
||||
expect(exported).not.toContain('private-')
|
||||
expect(exported).not.toContain(token)
|
||||
} finally {
|
||||
await client.close()
|
||||
await diagnostics.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('honors a requested knowledge result count above eight', async () => {
|
||||
const { service, retrieveMany } = createService()
|
||||
const [sample] = await retrieveMany([firstLibraryId])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from '../desktop-diagnostics'
|
||||
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
|
||||
import type { RuntimeTarget } from '../../shared/capability-contracts'
|
||||
|
||||
@@ -256,6 +257,7 @@ type DownstreamMcpSession = {
|
||||
}
|
||||
|
||||
export type KnowledgeMcpGatewayOptions = {
|
||||
observeFailure?: DesktopDiagnosticFailureObserver
|
||||
storyGraphService?: StoryGraphService
|
||||
maximumBodyBytes?: number
|
||||
magicNotesDatabase?: MagicNotesDatabase
|
||||
@@ -401,6 +403,7 @@ export class KnowledgeMcpGateway {
|
||||
): BrowserTabUsageLease
|
||||
}
|
||||
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
|
||||
private readonly observeFailure?: DesktopDiagnosticFailureObserver
|
||||
private server?: Server
|
||||
private endpoint?: string
|
||||
|
||||
@@ -416,6 +419,18 @@ export class KnowledgeMcpGateway {
|
||||
this.obsidianService = options.obsidianService
|
||||
this.browserService = options.browserService
|
||||
this.launchEnvironmentProvider = options.launchEnvironmentProvider
|
||||
this.observeFailure = options.observeFailure
|
||||
}
|
||||
|
||||
private observeMcpFailure(mcp: DesktopMcpDiagnosticMetadata): void {
|
||||
try {
|
||||
this.observeFailure?.({
|
||||
component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed',
|
||||
error: new Error('Local MCP request failed'), mcp
|
||||
})
|
||||
} catch {
|
||||
// Diagnostics must not change the HTTP response or session lifecycle.
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -423,7 +438,23 @@ export class KnowledgeMcpGateway {
|
||||
return
|
||||
}
|
||||
const server = createServer((request, response) => {
|
||||
void this.handleRequest(request, response).catch(() => {
|
||||
const context = { phase: 'request' as DesktopMcpDiagnosticMetadata['phase'], startedAt: performance.now(), correlationId: undefined as string | undefined }
|
||||
let failed = false
|
||||
response.once('finish', () => {
|
||||
if (!failed && response.statusCode >= 400) {
|
||||
this.observeMcpFailure({
|
||||
phase: context.phase, category: 'http-rejection', status: response.statusCode,
|
||||
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
|
||||
})
|
||||
}
|
||||
})
|
||||
void this.handleRequest(request, response, context).catch(() => {
|
||||
failed = true
|
||||
this.observeMcpFailure({
|
||||
phase: context.phase, category: 'handler-failure',
|
||||
...(response.headersSent ? {} : { status: 500 }),
|
||||
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
|
||||
})
|
||||
sendJson(response, 500, {
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32603, message: 'Internal server error' },
|
||||
@@ -1683,76 +1714,86 @@ export class KnowledgeMcpGateway {
|
||||
mcp.setRequestHandler(
|
||||
ListToolsRequestSchema,
|
||||
async (_request, extra) => {
|
||||
const customBindings = await this.getCustomMcpBindings(
|
||||
token,
|
||||
extra.signal
|
||||
)
|
||||
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
|
||||
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
|
||||
(name): Tool[] => {
|
||||
const definition = scopedDataToolByName.get(
|
||||
name as ScopedDataToolName
|
||||
)
|
||||
if (!definition) {
|
||||
return []
|
||||
}
|
||||
const inputSchema = z.toJSONSchema(
|
||||
definition.inputSchema,
|
||||
{ target: 'draft-7' }
|
||||
) as Tool['inputSchema'] & { $schema?: string }
|
||||
Reflect.deleteProperty(inputSchema, '$schema')
|
||||
return [
|
||||
{
|
||||
name,
|
||||
title: definition.title,
|
||||
description: definition.description,
|
||||
inputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: definition.access === 'read',
|
||||
destructiveHint:
|
||||
name === 'goodbuddy_config_apply' ||
|
||||
name === 'note_delete' ||
|
||||
name === 'note_entry_delete'
|
||||
}
|
||||
const startedAt = performance.now()
|
||||
try {
|
||||
const customBindings = await this.getCustomMcpBindings(
|
||||
token,
|
||||
extra.signal
|
||||
)
|
||||
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
|
||||
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
|
||||
(name): Tool[] => {
|
||||
const definition = scopedDataToolByName.get(
|
||||
name as ScopedDataToolName
|
||||
)
|
||||
if (!definition) {
|
||||
return []
|
||||
}
|
||||
const inputSchema = z.toJSONSchema(
|
||||
definition.inputSchema,
|
||||
{ target: 'draft-7' }
|
||||
) as Tool['inputSchema'] & { $schema?: string }
|
||||
Reflect.deleteProperty(inputSchema, '$schema')
|
||||
return [
|
||||
{
|
||||
name,
|
||||
title: definition.title,
|
||||
description: definition.description,
|
||||
inputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: definition.access === 'read',
|
||||
destructiveHint:
|
||||
name === 'goodbuddy_config_apply' ||
|
||||
name === 'note_delete' ||
|
||||
name === 'note_entry_delete'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
const capability = this.getCapability(token)
|
||||
const imageTool = await imageToolDefinition(capability.imageToolBinding)
|
||||
const browserTools = capability.browserConversationId && capability.browserTabId
|
||||
? new BrowserModelTools({
|
||||
service: this.browserService!,
|
||||
conversationId: capability.browserConversationId,
|
||||
browserTabId: capability.browserTabId
|
||||
})
|
||||
: undefined
|
||||
const browserDefinitions = browserTools
|
||||
? browserTools.listTools().map(
|
||||
(definition): Tool => ({
|
||||
name: definition.name,
|
||||
title: definition.displayName,
|
||||
description: definition.description,
|
||||
inputSchema: definition.inputSchema as Tool['inputSchema'],
|
||||
annotations: {
|
||||
readOnlyHint:
|
||||
definition.name === 'browser_snapshot' ||
|
||||
definition.name === 'browser_screenshot',
|
||||
destructiveHint: false
|
||||
}
|
||||
})
|
||||
)
|
||||
: []
|
||||
session.listedTools = true
|
||||
return {
|
||||
tools: [
|
||||
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
|
||||
...scopedTools,
|
||||
...browserDefinitions,
|
||||
...[...customBindings.values()].map(
|
||||
(binding) => binding.exposedTool
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
const capability = this.getCapability(token)
|
||||
const imageTool = await imageToolDefinition(capability.imageToolBinding)
|
||||
const browserTools = capability.browserConversationId && capability.browserTabId
|
||||
? new BrowserModelTools({
|
||||
service: this.browserService!,
|
||||
conversationId: capability.browserConversationId,
|
||||
browserTabId: capability.browserTabId
|
||||
})
|
||||
: undefined
|
||||
const browserDefinitions = browserTools
|
||||
? browserTools.listTools().map(
|
||||
(definition): Tool => ({
|
||||
name: definition.name,
|
||||
title: definition.displayName,
|
||||
description: definition.description,
|
||||
inputSchema: definition.inputSchema as Tool['inputSchema'],
|
||||
annotations: {
|
||||
readOnlyHint:
|
||||
definition.name === 'browser_snapshot' ||
|
||||
definition.name === 'browser_screenshot',
|
||||
destructiveHint: false
|
||||
}
|
||||
})
|
||||
)
|
||||
: []
|
||||
session.listedTools = true
|
||||
return {
|
||||
tools: [
|
||||
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
|
||||
...scopedTools,
|
||||
...browserDefinitions,
|
||||
...[...customBindings.values()].map(
|
||||
(binding) => binding.exposedTool
|
||||
)
|
||||
]
|
||||
} catch (error) {
|
||||
this.observeMcpFailure({
|
||||
phase: 'tool-discovery', category: 'handler-failure',
|
||||
elapsedMs: Math.round(performance.now() - startedAt),
|
||||
correlationId: `sha256:${createHash('sha256').update(token).digest('hex')}`
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1921,7 +1962,8 @@ export class KnowledgeMcpGateway {
|
||||
|
||||
private async handleRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse
|
||||
response: ServerResponse,
|
||||
context: { phase: DesktopMcpDiagnosticMetadata['phase']; startedAt: number; correlationId?: string }
|
||||
): Promise<void> {
|
||||
if (request.url !== '/mcp') {
|
||||
sendJson(response, 404, { error: 'Not found' })
|
||||
@@ -1940,6 +1982,8 @@ export class KnowledgeMcpGateway {
|
||||
})
|
||||
return
|
||||
}
|
||||
context.phase = 'authentication'
|
||||
context.startedAt = performance.now()
|
||||
const authorization = request.headers.authorization
|
||||
if (
|
||||
typeof authorization !== 'string' ||
|
||||
@@ -1949,6 +1993,7 @@ export class KnowledgeMcpGateway {
|
||||
return
|
||||
}
|
||||
const token = authorization.slice('Bearer '.length)
|
||||
context.correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}`
|
||||
try {
|
||||
this.getCapability(token)
|
||||
} catch {
|
||||
@@ -1956,6 +2001,8 @@ export class KnowledgeMcpGateway {
|
||||
return
|
||||
}
|
||||
|
||||
context.phase = 'request'
|
||||
context.startedAt = performance.now()
|
||||
let body: unknown
|
||||
if (request.method === 'POST') {
|
||||
try {
|
||||
@@ -1971,6 +2018,8 @@ export class KnowledgeMcpGateway {
|
||||
}
|
||||
}
|
||||
|
||||
context.phase = 'session'
|
||||
context.startedAt = performance.now()
|
||||
const sessionId = request.headers['mcp-session-id']
|
||||
let createdSession = false
|
||||
let session =
|
||||
@@ -2020,15 +2069,21 @@ export class KnowledgeMcpGateway {
|
||||
const availableTools = new Set(
|
||||
this.getAvailableToolNames(token)
|
||||
)
|
||||
context.phase = 'initialize'
|
||||
context.startedAt = performance.now()
|
||||
session = this.createDownstreamMcpSession(
|
||||
token,
|
||||
availableTools
|
||||
)
|
||||
createdSession = true
|
||||
this.downstreamMcpSessions.set(session.registryKey, session)
|
||||
await session.mcp.connect(session.transport)
|
||||
}
|
||||
try {
|
||||
if (createdSession) await session.mcp.connect(session.transport)
|
||||
if (body && typeof body === 'object' && 'method' in body && body.method === 'tools/list') {
|
||||
context.phase = 'tool-discovery'
|
||||
context.startedAt = performance.now()
|
||||
}
|
||||
await session.transport.handleRequest(request, response, body)
|
||||
} finally {
|
||||
if (createdSession && session.id === undefined) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -8,12 +9,112 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2'
|
||||
import { expect, it } from 'vitest'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
import type { DesktopDiagnosticFailure } from '../desktop-diagnostics'
|
||||
|
||||
const binaryPath = join(
|
||||
process.cwd(), '.runtime-resources', process.arch,
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
|
||||
)
|
||||
|
||||
it.skipIf(!existsSync(binaryPath))(
|
||||
'recovers a lost MCP add response with the real OpenCode binary and gateway',
|
||||
async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-mcp-retry-'))
|
||||
let modelCalls = 0
|
||||
const model = createServer((_request, response) => {
|
||||
modelCalls++
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end(`data: ${JSON.stringify({
|
||||
id: 'retry', object: 'chat.completion.chunk', created: 1, model: 'retry',
|
||||
choices: [{ index: 0, delta: { role: 'assistant', content: 'RETRY_OK' }, finish_reason: 'stop' }]
|
||||
})}\n\ndata: [DONE]\n\n`)
|
||||
})
|
||||
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
|
||||
const address = model.address()
|
||||
if (!address || typeof address === 'string') throw new Error('No model port')
|
||||
const gateway = new KnowledgeMcpGateway({} as never)
|
||||
await gateway.start()
|
||||
const controller = new AbortController()
|
||||
const requestId = crypto.randomUUID()
|
||||
const token = gateway.grant(requestId, [crypto.randomUUID()], controller.signal)!
|
||||
const records: DesktopDiagnosticFailure[] = []
|
||||
const operations: string[] = []
|
||||
const registrations: Array<{ name: string; config: { headers: { Authorization: string } } }> = []
|
||||
const runtime = new OpenCodeRuntime({
|
||||
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
|
||||
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
|
||||
observeFailure: failure => records.push(failure),
|
||||
modelProfile: {
|
||||
id: crypto.randomUUID(), name: 'Retry fixture', modelName: 'retry',
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
protocol: 'openai-chat-completions', authentication: 'none'
|
||||
}
|
||||
}, {
|
||||
createClient: options => createOpencodeClient({
|
||||
...options,
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const pathname = new URL(request.url).pathname
|
||||
if (pathname === '/mcp' && request.method === 'POST') {
|
||||
const registration = await request.clone().json() as typeof registrations[number]
|
||||
registrations.push(registration)
|
||||
const response = await fetch(request, init)
|
||||
const status = await response.clone().json() as Record<string, { status: string }>
|
||||
expect(status[registration.name]?.status).toBe('connected')
|
||||
operations.push('add-connected')
|
||||
if (registrations.length === 1) {
|
||||
// The server and gateway are already connected when the response is lost.
|
||||
await response.body?.cancel()
|
||||
throw new TypeError('fetch failed', { cause: Object.assign(new Error('socket reset'), { code: 'ECONNRESET' }) })
|
||||
}
|
||||
return response
|
||||
}
|
||||
const response = await fetch(request, init)
|
||||
if (pathname.endsWith('/disconnect')) {
|
||||
expect(await response.clone().json()).toBe(true)
|
||||
operations.push('disconnect-succeeded')
|
||||
}
|
||||
return response
|
||||
}
|
||||
})
|
||||
})
|
||||
const deadline = setTimeout(() => controller.abort(), 30_000)
|
||||
try {
|
||||
let text = ''
|
||||
for await (const event of runtime.run({
|
||||
requestId, conversationId: crypto.randomUUID(), workMode: 'ask',
|
||||
prompt: 'Reply RETRY_OK without tools.', knowledgeCapabilityToken: token
|
||||
}, controller.signal)) {
|
||||
if (event.type === 'text') text += event.delta
|
||||
}
|
||||
expect(text).toBe('RETRY_OK')
|
||||
expect(modelCalls).toBe(1)
|
||||
expect(operations).toEqual(['add-connected', 'disconnect-succeeded', 'add-connected', 'disconnect-succeeded'])
|
||||
expect(registrations).toHaveLength(2)
|
||||
expect(registrations[1]).toEqual(registrations[0])
|
||||
expect(registrations[0]?.config.headers.Authorization).toBe(`Bearer ${token}`)
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0]?.mcp).toMatchObject({
|
||||
phase: 'connect', category: 'transport-failure', attempt: 1, kind: 'builtin',
|
||||
correlationId: `sha256:${createHash('sha256').update(token).digest('hex')}`
|
||||
})
|
||||
expect(JSON.stringify(records)).not.toContain(token)
|
||||
const unauthorized = await fetch(gateway.getEndpoint()!, { method: 'POST' })
|
||||
expect(unauthorized.status).toBe(401)
|
||||
await unauthorized.body?.cancel()
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
await runtime.dispose()
|
||||
gateway.revoke(token)
|
||||
await gateway.dispose()
|
||||
model.closeAllConnections()
|
||||
await new Promise<void>(resolve => model.close(() => resolve()))
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
60_000
|
||||
)
|
||||
|
||||
it.skipIf(!existsSync(binaryPath))(
|
||||
'closes real event streams after parallel runs and compaction without cancelling a peer',
|
||||
async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import {
|
||||
mkdir,
|
||||
@@ -11,11 +12,12 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import type spawn from "cross-spawn";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { KnowledgeMcpGateway } from "./knowledge-mcp-gateway";
|
||||
import type { RuntimeEvent } from "./runtime";
|
||||
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from "../desktop-diagnostics";
|
||||
import {
|
||||
createTemporaryMcpName,
|
||||
OpenCodeRuntime,
|
||||
@@ -3220,6 +3222,370 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it.each(["knowledge", "custom"].flatMap<{
|
||||
kind: string; scenario: string; detail: string; attempts: number;
|
||||
succeeds?: boolean; thrown?: boolean; status?: string; apiError?: boolean;
|
||||
}>((kind) => [
|
||||
{ kind, scenario: "retry success", detail: "MCP error -32001: Request timed out", attempts: 2, succeeds: true },
|
||||
{ kind, scenario: "retry exhausted", detail: "connect ECONNREFUSED", attempts: 2 },
|
||||
{ kind, scenario: "thrown connect error", detail: "connect ECONNRESET", attempts: 2, succeeds: true, thrown: true },
|
||||
{ kind, scenario: "auth", detail: "HTTP 401 Unauthorized; ECONNRESET", attempts: 1 },
|
||||
{ kind, scenario: "OAuth", detail: "OAuth error: request timed out", attempts: 1 },
|
||||
{ kind, scenario: "protocol", detail: "Invalid protocol response; connection closed", attempts: 1 },
|
||||
{ kind, scenario: "unknown", detail: "Unexpected initialization failure", attempts: 1 },
|
||||
{ kind, scenario: "plain failed", detail: "failed", attempts: 1 },
|
||||
{ kind, scenario: "needs auth", detail: "needs_auth", attempts: 1, status: "needs_auth" },
|
||||
{ kind, scenario: "missing status", detail: "unknown", attempts: 1, status: "missing" },
|
||||
{ kind, scenario: "API error", detail: "HTTP 403 Forbidden", attempts: 1, apiError: true },
|
||||
]))("handles $kind MCP initialization: $scenario", async ({ kind, detail, attempts, succeeds, thrown, status, apiError }) => {
|
||||
const setup = runClient([
|
||||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||||
]);
|
||||
const add = vi.mocked(setup.client.mcp.add);
|
||||
const disconnect = vi.mocked(setup.client.mcp.disconnect);
|
||||
const secret = "request-private-capability";
|
||||
const failure = `${detail}\nAuthorization: Bearer ${secret}\npassword=private-password\nhttps://user:private-password@example.com/ECONNRESET?token=private-query\n${"x".repeat(3_000)}`;
|
||||
const fail = async (input?: { name?: string }) => {
|
||||
if (thrown) throw new Error(failure);
|
||||
if (apiError) return { error: { name: "UnknownError", data: { message: failure } }, response: { status: 403 } };
|
||||
return { data: status === "missing" ? {} : {
|
||||
[input!.name!]: { status: status ?? "failed", error: failure },
|
||||
} };
|
||||
};
|
||||
// The SDK returns a map keyed by the registered name, not a top-level status.
|
||||
if (succeeds) add.mockImplementationOnce(fail as never);
|
||||
else add.mockImplementation(fail as never);
|
||||
const gateway = {
|
||||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||||
getAvailableToolNames: () => ["knowledge_search"],
|
||||
grantCustomMcp: vi.fn(() => secret),
|
||||
prepareCustomMcpTools: vi.fn(async () => [{ name: "custom_tool" }]),
|
||||
revoke: vi.fn(),
|
||||
};
|
||||
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
|
||||
const runtime = embeddedRuntime(setup.client, {
|
||||
observeFailure,
|
||||
knowledgeGateway: gateway as unknown as KnowledgeMcpGateway,
|
||||
mcpServers: kind === "custom" ? [{
|
||||
id: "custom", name: "Custom", description: "", transport: "http",
|
||||
url: "https://example.com/mcp", enabled: true, allowDynamicTools: false,
|
||||
assignments: ["opencode"], secretConfigured: false,
|
||||
}] : [],
|
||||
});
|
||||
const collect = async () => {
|
||||
const events: RuntimeEvent[] = [];
|
||||
for await (const event of runtime.run({
|
||||
requestId: "request-1", conversationId: "conversation-1", prompt: "test",
|
||||
workMode: kind === "custom" ? "execute" : "ask",
|
||||
...(kind === "knowledge" ? { knowledgeCapabilityToken: secret } : {}),
|
||||
}, new AbortController().signal)) events.push(event);
|
||||
return events;
|
||||
};
|
||||
try {
|
||||
if (succeeds) {
|
||||
await expect(collect()).resolves.toContainEqual(expect.objectContaining({ type: "done" }));
|
||||
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
|
||||
} else {
|
||||
const error = await collect().catch((error: unknown) => error) as Error;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toContain(detail);
|
||||
expect(error.message).not.toMatch(/request-private-capability|private-password|private-query/);
|
||||
expect(error.message.length).toBeLessThan(2_100);
|
||||
expect(setup.session.promptAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(add).toHaveBeenCalledTimes(attempts);
|
||||
expect(disconnect).toHaveBeenCalledTimes(attempts);
|
||||
expect(observeFailure).toHaveBeenCalledTimes(succeeds ? 1 : attempts);
|
||||
for (const [index, [record]] of observeFailure.mock.calls.entries()) {
|
||||
expect(record).toMatchObject({
|
||||
component: "runtime", stage: "connect", code: "runtime.mcp.failed",
|
||||
mcp: {
|
||||
phase: "connect", attempt: index + 1, kind: kind === "knowledge" ? "builtin" : "custom",
|
||||
correlationId: `sha256:${createHash("sha256").update(secret).digest("hex")}`,
|
||||
elapsedMs: expect.any(Number),
|
||||
category: apiError ? "http-rejection" : thrown ? "transport-failure" : "initialization-failure",
|
||||
...(apiError ? { status: 403 } : {}),
|
||||
},
|
||||
});
|
||||
expect(record.mcp!.elapsedMs).toBeGreaterThanOrEqual(0);
|
||||
expect(JSON.stringify(record)).not.toMatch(/request-private-capability|private-password|private-query|example.com|ECONNRESET/);
|
||||
expect((record.error as Error).message).toBe("Local MCP operation failed");
|
||||
}
|
||||
if (attempts === 2) {
|
||||
expect(add.mock.calls[1]![0]).toEqual(add.mock.calls[0]![0]);
|
||||
expect(disconnect.mock.invocationCallOrder[0]).toBeGreaterThan(add.mock.invocationCallOrder[0]!);
|
||||
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(add.mock.invocationCallOrder[1]!);
|
||||
}
|
||||
expect(add.mock.calls[0]![0]?.config).toMatchObject({
|
||||
headers: { Authorization: `Bearer ${secret}` }, oauth: false,
|
||||
});
|
||||
if (kind === "custom") {
|
||||
expect(gateway.grantCustomMcp).toHaveBeenCalledOnce();
|
||||
expect(gateway.prepareCustomMcpTools).toHaveBeenCalledOnce();
|
||||
expect(gateway.revoke).toHaveBeenCalledExactlyOnceWith(secret);
|
||||
}
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["recovered", "exhausted", "cleanup-false", "cleanup-transport", "cleanup-timeout", "http-401", "http-503", "unknown"])(
|
||||
"handles installed SDK fetch results: %s", async (scenario) => {
|
||||
const setup = runClient([
|
||||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||||
]);
|
||||
const token = "private-sdk-capability";
|
||||
const cause = Object.assign(new Error(`socket reset\nAuthorization: Bearer ${token}\npassword=private-password\nhttps://user:private-password@example.com/private-path`), { code: "ECONNRESET" });
|
||||
// A circular cause must not cause unbounded traversal or expose a stack.
|
||||
Object.assign(cause, { cause });
|
||||
const failure = new TypeError("fetch failed", { cause });
|
||||
const order: string[] = [];
|
||||
const signals: AbortSignal[] = [];
|
||||
let adds = 0;
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: "http://127.0.0.1:4096", throwOnError: false,
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
if (new URL(request.url).pathname.endsWith("/disconnect")) {
|
||||
order.push("disconnect");
|
||||
signals.push(request.signal);
|
||||
if (scenario === "cleanup-transport") throw failure;
|
||||
if (scenario === "cleanup-timeout") return new Promise<Response>(() => {});
|
||||
return Response.json(scenario !== "cleanup-false");
|
||||
}
|
||||
order.push("add");
|
||||
adds++;
|
||||
if (scenario.startsWith("http-")) {
|
||||
return Response.json({ name: "UnknownError", data: { message: `ECONNRESET password=private-password` } }, { status: Number(scenario.slice(5)) });
|
||||
}
|
||||
if (scenario === "unknown") throw new TypeError("fetch failed");
|
||||
if (adds === 1 || scenario === "exhausted") throw failure;
|
||||
const { name } = await request.json() as { name: string };
|
||||
return Response.json({ [name]: { status: "connected" } });
|
||||
},
|
||||
});
|
||||
Object.assign(setup.client, { mcp: sdk.mcp });
|
||||
const add = vi.spyOn(sdk.mcp, "add");
|
||||
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
|
||||
const runtime = embeddedRuntime(setup.client, {
|
||||
observeFailure,
|
||||
knowledgeGateway: {
|
||||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||||
getAvailableToolNames: () => ["knowledge_search"],
|
||||
} as unknown as KnowledgeMcpGateway,
|
||||
});
|
||||
const collect = async () => {
|
||||
for await (const event of runtime.run({
|
||||
requestId: "sdk-request", conversationId: "sdk-conversation", prompt: "test", workMode: "ask",
|
||||
knowledgeCapabilityToken: token,
|
||||
}, new AbortController().signal)) void event;
|
||||
};
|
||||
try {
|
||||
if (scenario === "recovered") {
|
||||
await collect();
|
||||
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
|
||||
} else {
|
||||
const error = await collect().catch((error: unknown) => error) as Error;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toContain(scenario === "unknown" ? "fetch failed" : "ECONNRESET");
|
||||
expect(error.message).not.toMatch(/private-|example\.com|socket reset.*at /);
|
||||
expect(error.message.length).toBeLessThan(2_100);
|
||||
expect(setup.session.promptAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
const first = await add.mock.results[0]!.value;
|
||||
if (scenario.startsWith("http-")) {
|
||||
expect(first.response.status).toBe(Number(scenario.slice(5)));
|
||||
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "http-rejection", status: Number(scenario.slice(5)) });
|
||||
} else {
|
||||
expect(first.response).toBeUndefined();
|
||||
expect(first.error).toBeInstanceOf(TypeError);
|
||||
if (scenario !== "unknown") expect(first.error).toBe(failure);
|
||||
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "transport-failure" });
|
||||
}
|
||||
expect(add).toHaveBeenCalledTimes(scenario === "recovered" || scenario === "exhausted" ? 2 : 1);
|
||||
expect(order).toEqual(scenario === "recovered" || scenario === "exhausted"
|
||||
? ["add", "disconnect", "add", "disconnect"]
|
||||
: scenario.startsWith("cleanup-") ? ["add", "disconnect", "disconnect"] : ["add", "disconnect"]);
|
||||
if (scenario === "cleanup-timeout") expect(signals.every(signal => signal.aborted)).toBe(true);
|
||||
const records = observeFailure.mock.calls.map(([record]) => record);
|
||||
expect(JSON.stringify(records)).not.toMatch(/private-|ECONNRESET|socket reset|example\.com/);
|
||||
expect(records.every(record => record.mcp?.correlationId === `sha256:${createHash("sha256").update(token).digest("hex")}`)).toBe(true);
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("persists a recovered MCP attempt even when the observer throws", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "goodbuddy-mcp-diagnostics-"));
|
||||
const diagnostics = new DesktopDiagnostics(directory);
|
||||
const setup = runClient([
|
||||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||||
]);
|
||||
const token = "private-correlation-token";
|
||||
vi.mocked(setup.client.mcp.add).mockImplementationOnce((async (input: { name: string }) => ({
|
||||
data: { [input.name]: { status: "failed", error: `connect ECONNRESET Authorization: Bearer ${token}` } },
|
||||
})) as never);
|
||||
const writes: Promise<void>[] = [];
|
||||
const observer = vi.fn<DesktopDiagnosticFailureObserver>((failure) => {
|
||||
writes.push(diagnostics.recordFailure(failure));
|
||||
throw new Error("observer failed");
|
||||
});
|
||||
const runtime = embeddedRuntime(setup.client, {
|
||||
observeFailure: observer,
|
||||
knowledgeGateway: {
|
||||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||||
getAvailableToolNames: () => ["knowledge_search"],
|
||||
} as unknown as KnowledgeMcpGateway,
|
||||
});
|
||||
try {
|
||||
for await (const event of runtime.run({
|
||||
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
|
||||
knowledgeCapabilityToken: token,
|
||||
}, new AbortController().signal)) void event;
|
||||
expect(setup.client.mcp.add).toHaveBeenCalledTimes(2);
|
||||
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
|
||||
expect(observer).toHaveBeenCalledOnce();
|
||||
await Promise.all(writes);
|
||||
await diagnostics.dispose();
|
||||
const restarted = new DesktopDiagnostics(directory);
|
||||
try {
|
||||
const records = await restarted.readRecent();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.mcp).toMatchObject({
|
||||
phase: "connect", attempt: 1, kind: "builtin",
|
||||
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
|
||||
});
|
||||
expect((await restarted.exportRecent()).toString()).not.toMatch(/private-|ECONNRESET|Authorization|127\.0\.0\.1/);
|
||||
} finally { await restarted.dispose(); }
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
await diagnostics.dispose();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["reject", "error", "false", "timeout"])("does not retry or reuse the slot after MCP cleanup %s", async (failure) => {
|
||||
const setup = runClient([
|
||||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||||
]);
|
||||
const add = vi.mocked(setup.client.mcp.add);
|
||||
add.mockImplementationOnce((async (input: { name: string }) => ({
|
||||
data: { [input.name]: { status: "failed", error: "connect ECONNREFUSED" } },
|
||||
})) as never);
|
||||
const disconnect = vi.mocked(setup.client.mcp.disconnect);
|
||||
if (failure === "reject") disconnect.mockRejectedValue(new Error("cleanup failed"));
|
||||
if (failure === "error") disconnect.mockResolvedValue({ error: { message: "cleanup failed" } } as never);
|
||||
if (failure === "false") disconnect.mockResolvedValue({ data: false } as never);
|
||||
if (failure === "timeout") disconnect.mockImplementation((() => new Promise(() => {})) as never);
|
||||
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { throw new Error("observer failed"); });
|
||||
const runtime = embeddedRuntime(setup.client, {
|
||||
observeFailure,
|
||||
knowledgeGateway: {
|
||||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||||
getAvailableToolNames: () => ["knowledge_search"],
|
||||
} as unknown as KnowledgeMcpGateway,
|
||||
});
|
||||
const collect = async (requestId: string) => {
|
||||
for await (const event of runtime.run({
|
||||
requestId, conversationId: "conversation-1", prompt: "test", workMode: "ask",
|
||||
knowledgeCapabilityToken: requestId,
|
||||
}, new AbortController().signal)) void event;
|
||||
};
|
||||
try {
|
||||
await expect(collect("first")).rejects.toThrow("ECONNREFUSED");
|
||||
expect(add).toHaveBeenCalledOnce();
|
||||
expect(setup.session.promptAsync).not.toHaveBeenCalled();
|
||||
expect(observeFailure).toHaveBeenCalledTimes(3);
|
||||
expect(observeFailure.mock.calls.slice(1).map(([record]) => record.mcp)).toEqual([
|
||||
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin", category: failure === "false" ? "cleanup-failure" : "transport-failure" }),
|
||||
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin" }),
|
||||
]);
|
||||
expect(new Set(observeFailure.mock.calls.map(([record]) => record.mcp?.correlationId)).size).toBe(1);
|
||||
disconnect.mockResolvedValue({ data: true } as never);
|
||||
await collect("second");
|
||||
expect(add.mock.calls[1]![0]?.name).toBe(`${add.mock.calls[0]![0]?.name}-2`);
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["add", "disconnect", "retry"])("respects cancellation during MCP %s", async (phase) => {
|
||||
const setup = runClient([]);
|
||||
const controller = new AbortController();
|
||||
const add = vi.mocked(setup.client.mcp.add);
|
||||
add.mockImplementation((async (input: { name: string }) => {
|
||||
if (phase === "add" || (phase === "retry" && add.mock.calls.length === 2)) {
|
||||
controller.abort(new Error("cancel initialization"));
|
||||
}
|
||||
return { data: { [input.name]: { status: "failed", error: "connect ECONNRESET" } } };
|
||||
}) as never);
|
||||
const disconnect = vi.mocked(setup.client.mcp.disconnect);
|
||||
disconnect.mockImplementation((async () => {
|
||||
if (phase === "disconnect") controller.abort(new Error("cancel initialization"));
|
||||
return { data: true };
|
||||
}) as never);
|
||||
const runtime = embeddedRuntime(setup.client, {
|
||||
knowledgeGateway: {
|
||||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||||
getAvailableToolNames: () => ["knowledge_search"],
|
||||
} as unknown as KnowledgeMcpGateway,
|
||||
});
|
||||
try {
|
||||
await expect((async () => {
|
||||
for await (const event of runtime.run({
|
||||
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
|
||||
knowledgeCapabilityToken: "private-token",
|
||||
}, controller.signal)) void event;
|
||||
})()).rejects.toThrow("cancel initialization");
|
||||
expect(add).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
|
||||
expect(disconnect).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
|
||||
expect(setup.session.promptAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not retry MCP initialization or the prompt after a transient tool execution error", async () => {
|
||||
const setup = runClient([
|
||||
{
|
||||
id: "tool-error", type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
part: {
|
||||
id: "part-1", callID: "call-1", type: "tool", tool: "knowledge_search",
|
||||
state: {
|
||||
status: "error", input: {}, error: "connect ECONNRESET",
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||||
]);
|
||||
const runtime = embeddedRuntime(setup.client, {
|
||||
knowledgeGateway: {
|
||||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||||
getAvailableToolNames: () => ["knowledge_search"],
|
||||
} as unknown as KnowledgeMcpGateway,
|
||||
});
|
||||
try {
|
||||
const events: RuntimeEvent[] = [];
|
||||
await expect((async () => {
|
||||
for await (const event of runtime.run({
|
||||
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
|
||||
knowledgeCapabilityToken: "private-token",
|
||||
}, new AbortController().signal)) events.push(event);
|
||||
})()).rejects.toThrow("connect ECONNRESET");
|
||||
expect(events).toContainEqual(expect.objectContaining({ type: "tool", state: "failed" }));
|
||||
expect(setup.client.mcp.add).toHaveBeenCalledOnce();
|
||||
expect(setup.client.mcp.disconnect).toHaveBeenCalledOnce();
|
||||
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps temporary MCP names stable within a conversation and short enough for providers", async () => {
|
||||
const setup = runClient([
|
||||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||||
@@ -3400,7 +3766,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it("runs embedded conversations concurrently while serializing MCP registrations", async () => {
|
||||
it.each([false, true])("runs embedded conversations concurrently while serializing MCP registrations (retry=%s)", async (retry) => {
|
||||
const setup = runClient([
|
||||
{
|
||||
id: "idle",
|
||||
@@ -3437,7 +3803,9 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
await firstAdd;
|
||||
return {
|
||||
data: {
|
||||
[input.name]: { status: "connected" },
|
||||
[input.name]: retry
|
||||
? { status: "failed", error: "connect ECONNRESET" }
|
||||
: { status: "connected" },
|
||||
},
|
||||
error: undefined,
|
||||
};
|
||||
@@ -3500,7 +3868,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
} finally {
|
||||
resolveFirstAdd();
|
||||
}
|
||||
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(retry ? 3 : 2));
|
||||
await Promise.all([first, second]);
|
||||
expect(
|
||||
mcpAdd.mock.calls.map(
|
||||
@@ -3511,8 +3879,10 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
}
|
||||
).config.headers.Authorization,
|
||||
),
|
||||
).toEqual(["Bearer first-token", "Bearer second-token"]);
|
||||
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(2);
|
||||
).toEqual(retry
|
||||
? ["Bearer first-token", "Bearer first-token", "Bearer second-token"]
|
||||
: ["Bearer first-token", "Bearer second-token"]);
|
||||
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(retry ? 3 : 2);
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,8 @@ import {
|
||||
runtimePrivacyEnvironment,
|
||||
} from "./process-environment";
|
||||
import type { LaunchEnvironmentProvider } from "../local-tool-environment/launch-environment-provider";
|
||||
import { boundedToolDetail, safeToolErrorDetail } from "./approval-summary";
|
||||
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from "../desktop-diagnostics";
|
||||
import { boundedToolDetail, redactSensitiveText, safeToolErrorDetail } from "./approval-summary";
|
||||
import type {
|
||||
ResolvedMcpServer,
|
||||
RuntimeSkillPackage,
|
||||
@@ -503,6 +504,7 @@ export type OpenCodeRuntimeDependencies = {
|
||||
};
|
||||
|
||||
export type OpenCodeRuntimeOptions = {
|
||||
observeFailure?: DesktopDiagnosticFailureObserver;
|
||||
baseUrl?: string;
|
||||
embedded: boolean;
|
||||
binaryPath: string;
|
||||
@@ -2239,7 +2241,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
let knowledgeToolIds: string[] = [];
|
||||
let customMcpName: string | undefined;
|
||||
let customMcpToken: string | undefined;
|
||||
const attemptedMcpNames: string[] = [];
|
||||
type McpAttempt = Pick<DesktopMcpDiagnosticMetadata, "correlationId" | "kind" | "attempt">;
|
||||
const attemptedMcpNames = new Map<string, McpAttempt>();
|
||||
const mcpConversationKey = temporaryMcpConversationKey(
|
||||
request.conversationId,
|
||||
);
|
||||
@@ -2255,6 +2258,96 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
? this.options.knowledgeGateway?.bindImageTool(request.imageToolBinding, signal, request.knowledgeCapabilityToken)
|
||||
: undefined;
|
||||
const scopedCapabilityToken = imageCapabilityToken ?? request.knowledgeCapabilityToken;
|
||||
const observeMcpFailure = (mcp: DesktopMcpDiagnosticMetadata): void => {
|
||||
try {
|
||||
this.options.observeFailure?.({
|
||||
component: "runtime", stage: mcp.phase, code: "runtime.mcp.failed",
|
||||
error: new Error("Local MCP operation failed"), mcp,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics must not affect retry, cancellation, or cleanup.
|
||||
}
|
||||
};
|
||||
const disconnectMcp = async (name: string, metadata: McpAttempt): Promise<boolean> => {
|
||||
// Called under the mutation lock; queue wait does not consume this budget.
|
||||
const cleanupSignal = AbortSignal.timeout(1_000);
|
||||
const startedAt = performance.now();
|
||||
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
|
||||
let status: number | undefined;
|
||||
try {
|
||||
const result = await awaitWithAbort(
|
||||
client.mcp.disconnect({ name, directory }, { signal: cleanupSignal }),
|
||||
cleanupSignal,
|
||||
);
|
||||
if (result.data === true && !result.error) return true;
|
||||
status = result.response?.status;
|
||||
category = result.error && !result.response ? "transport-failure"
|
||||
: status && status >= 400 ? "http-rejection" : "cleanup-failure";
|
||||
} catch {
|
||||
// The uncertain connection keeps its slot until cleanup succeeds.
|
||||
}
|
||||
observeMcpFailure({ ...metadata, phase: "disconnect", category, status,
|
||||
elapsedMs: Math.round(performance.now() - startedAt) });
|
||||
return false;
|
||||
};
|
||||
const addMcp = (name: string, url: string, token: string, label: string) =>
|
||||
this.mutateMcp(signal, async () => {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
signal.throwIfAborted();
|
||||
const startedAt = performance.now();
|
||||
const metadata: McpAttempt = {
|
||||
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
|
||||
kind: name.startsWith(KNOWLEDGE_MCP_PREFIX) ? "builtin" : "custom",
|
||||
attempt: attempt === 0 ? 1 : 2,
|
||||
};
|
||||
let failure: unknown;
|
||||
let retryableStatus = true;
|
||||
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
|
||||
let httpStatus: number | undefined;
|
||||
try {
|
||||
const added = await this.controlRequest(label, (controlSignal) => {
|
||||
attemptedMcpNames.set(name, metadata);
|
||||
return client.mcp.add({
|
||||
directory, name,
|
||||
config: {
|
||||
type: "remote", url, enabled: true,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
oauth: false,
|
||||
},
|
||||
}, { signal: controlSignal });
|
||||
}, signal);
|
||||
const status = added.data?.[name];
|
||||
httpStatus = added.response?.status;
|
||||
const httpRejected = httpStatus !== undefined && httpStatus >= 400;
|
||||
if (!httpRejected && !added.error && status?.status === "connected") return;
|
||||
// With throwOnError:false the SDK resolves fetch failures without a response.
|
||||
const transportFailure = !!added.error && !added.response;
|
||||
category = transportFailure ? "transport-failure"
|
||||
: httpRejected ? "http-rejection" : "initialization-failure";
|
||||
retryableStatus = transportFailure || (!httpRejected && !added.error && status?.status === "failed");
|
||||
failure = added.error ?? (status && "error" in status
|
||||
? `${status.status}: ${status.error}`
|
||||
: status?.status ?? "unknown");
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
observeMcpFailure({ ...metadata, phase: "connect",
|
||||
category: signal.aborted ? "cancelled" : category, status: httpStatus,
|
||||
elapsedMs: Math.round(performance.now() - startedAt) });
|
||||
signal.throwIfAborted();
|
||||
const failureDetail = safeToolErrorDetail(failure) ?? "unknown";
|
||||
const detail = redactSensitiveText(
|
||||
failureDetail.split(token).join("[REDACTED]"),
|
||||
).replace(/https?:\/\/[^\s<>"']+/giu, "[URL]");
|
||||
const error = new Error(`OpenCode ${label}失败(${httpStatus && httpStatus >= 400 ? `HTTP ${httpStatus}: ` : ""}${detail})`);
|
||||
// Only explicit transport failures during initialization may be retried.
|
||||
const transient = /\b(?:ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE)\b|\b(?:connection closed|socket hang up|request timed out)\b/iu.test(detail)
|
||||
&& !/\b(?:auth(?:entication|orization)?\s+(?:failed|required|error|denied)|oauth|credentials|unauthorized|forbidden|401|403|protocol|invalid|parse|json|method)\b/iu.test(failureDetail);
|
||||
if (attempt > 0 || !retryableStatus || !transient) throw error;
|
||||
if (!await disconnectMcp(name, metadata)) throw error;
|
||||
attemptedMcpNames.delete(name);
|
||||
}
|
||||
});
|
||||
try {
|
||||
if (
|
||||
scopedCapabilityToken &&
|
||||
@@ -2263,42 +2356,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
) {
|
||||
const knowledgeEndpoint = this.options.knowledgeGateway.getEndpoint()!;
|
||||
knowledgeMcpName = temporaryMcpName(KNOWLEDGE_MCP_PREFIX);
|
||||
const added = await this.mutateMcp(signal, () =>
|
||||
this.controlRequest(
|
||||
"连接内置只读工具",
|
||||
(controlSignal) => {
|
||||
attemptedMcpNames.push(knowledgeMcpName!);
|
||||
return client.mcp.add(
|
||||
{
|
||||
directory,
|
||||
name: knowledgeMcpName,
|
||||
config: {
|
||||
type: "remote",
|
||||
url: knowledgeEndpoint,
|
||||
enabled: true,
|
||||
headers: {
|
||||
Authorization: `Bearer ${scopedCapabilityToken}`,
|
||||
},
|
||||
oauth: false,
|
||||
},
|
||||
},
|
||||
{ signal: controlSignal },
|
||||
);
|
||||
},
|
||||
signal,
|
||||
),
|
||||
);
|
||||
if (added.error || !added.data) {
|
||||
throw new Error(
|
||||
opencodeErrorMessage(added.error, "OpenCode 内置只读工具连接失败"),
|
||||
);
|
||||
}
|
||||
const addedStatus = added.data[knowledgeMcpName];
|
||||
if (!addedStatus || addedStatus.status !== "connected") {
|
||||
throw new Error(
|
||||
`OpenCode 内置只读工具连接失败(${addedStatus?.status ?? "unknown"})`,
|
||||
);
|
||||
}
|
||||
await addMcp(knowledgeMcpName, knowledgeEndpoint, scopedCapabilityToken, "连接内置只读工具");
|
||||
// OpenCode 1.18.x does not include dynamically added MCP tools in
|
||||
// experimental/tool/ids. Its model tool namespace is deterministic:
|
||||
// "<MCP server name>_<declared tool name>".
|
||||
@@ -2325,42 +2383,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
signal,
|
||||
);
|
||||
customMcpName = temporaryMcpName(CUSTOM_MCP_PREFIX);
|
||||
const added = await this.mutateMcp(signal, () =>
|
||||
this.controlRequest(
|
||||
"连接自定义 MCP 工具",
|
||||
(controlSignal) => {
|
||||
attemptedMcpNames.push(customMcpName!);
|
||||
return client.mcp.add(
|
||||
{
|
||||
directory,
|
||||
name: customMcpName,
|
||||
config: {
|
||||
type: "remote",
|
||||
url: customMcpEndpoint,
|
||||
enabled: true,
|
||||
headers: {
|
||||
Authorization: `Bearer ${customMcpToken}`,
|
||||
},
|
||||
oauth: false,
|
||||
},
|
||||
},
|
||||
{ signal: controlSignal },
|
||||
);
|
||||
},
|
||||
signal,
|
||||
),
|
||||
);
|
||||
const addedStatus = added.data?.[customMcpName];
|
||||
if (
|
||||
added.error ||
|
||||
!added.data ||
|
||||
!addedStatus ||
|
||||
addedStatus.status !== "connected"
|
||||
) {
|
||||
throw new Error(
|
||||
`OpenCode 自定义 MCP 连接失败(${addedStatus?.status ?? "unknown"})`,
|
||||
);
|
||||
}
|
||||
await addMcp(customMcpName, customMcpEndpoint, customMcpToken, "连接自定义 MCP 工具");
|
||||
knowledgeToolIds.push(
|
||||
...tools.map((tool) => `${customMcpName}_${tool.name}`),
|
||||
);
|
||||
@@ -3193,23 +3216,10 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
subscriptionController.abort();
|
||||
if (imageCapabilityToken && imageCapabilityToken !== request.knowledgeCapabilityToken) this.options.knowledgeGateway?.revoke(imageCapabilityToken);
|
||||
let allMcpDisconnected = true;
|
||||
for (const name of attemptedMcpNames) {
|
||||
for (const [name, metadata] of attemptedMcpNames) {
|
||||
const disconnected = await this.mutateMcp(
|
||||
new AbortController().signal,
|
||||
() => {
|
||||
// Queue wait must not consume the disconnect timeout.
|
||||
const cleanupSignal = AbortSignal.timeout(1_000);
|
||||
return awaitWithAbort(
|
||||
client.mcp.disconnect(
|
||||
{ name, directory },
|
||||
{ signal: cleanupSignal },
|
||||
),
|
||||
cleanupSignal,
|
||||
);
|
||||
},
|
||||
).then(
|
||||
(result) => !(result as { error?: unknown } | undefined)?.error,
|
||||
() => false,
|
||||
() => disconnectMcp(name, metadata),
|
||||
);
|
||||
allMcpDisconnected &&= disconnected;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,34 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ApplicationSettingsStore', () => {
|
||||
it.each([11, 12])('defaults missing frosted glass to off in version %s', async (version) => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
|
||||
delete legacy.transparentFrostedEffectEnabled
|
||||
await writeFile(filePath, JSON.stringify(legacy))
|
||||
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
|
||||
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, transparentFrostedEffectEnabled: undefined }).transparentFrostedEffectEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('persists and publishes both frosted glass choices across unrelated updates and reloads', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
expect((await store.get()).transparentFrostedEffectEnabled).toBe(false)
|
||||
const changed = vi.fn()
|
||||
store.onChanged(changed)
|
||||
for (const transparentFrostedEffectEnabled of [true, false]) {
|
||||
const saved = await store.update({ transparentFrostedEffectEnabled })
|
||||
expect(saved.transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
|
||||
expect(changed).toHaveBeenLastCalledWith(saved)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect((await createApplicationSettingsStore(filePath).get()).transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8')).transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
|
||||
}
|
||||
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
|
||||
for (const transparentFrostedEffectEnabled of ['false', 0, null]) {
|
||||
await expect(store.update({ transparentFrostedEffectEnabled })).rejects.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([11, 12])('defaults missing desktop notifications to on in version %s', async (version) => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
|
||||
|
||||
@@ -251,6 +251,7 @@ export const resolveLegacyLocalToolEnvironmentPaths: LegacyLocalToolEnvironmentR
|
||||
export const defaultApplicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -291,6 +292,7 @@ export class ApplicationSettingsStore {
|
||||
StoredApplicationSettings,
|
||||
| 'conversationHtmlRenderingEnabled'
|
||||
| 'desktopNotificationsEnabled'
|
||||
| 'transparentFrostedEffectEnabled'
|
||||
| 'localToolEnvironment'
|
||||
| 'version'
|
||||
| 'applicationNavigation'
|
||||
@@ -526,6 +528,7 @@ export class ApplicationSettingsStore {
|
||||
return {
|
||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
||||
desktopNotificationsEnabled: stored.desktopNotificationsEnabled,
|
||||
transparentFrostedEffectEnabled: stored.transparentFrostedEffectEnabled,
|
||||
updateSource: stored.updateSource,
|
||||
modelDownloadSource: stored.modelDownloadSource,
|
||||
localToolEnvironment: stored.localToolEnvironment,
|
||||
@@ -572,6 +575,7 @@ export class ApplicationSettingsStore {
|
||||
const settings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||
desktopNotificationsEnabled: next.desktopNotificationsEnabled,
|
||||
transparentFrostedEffectEnabled: next.transparentFrostedEffectEnabled,
|
||||
updateSource: next.updateSource,
|
||||
modelDownloadSource: next.modelDownloadSource,
|
||||
localToolEnvironment: next.localToolEnvironment,
|
||||
|
||||
@@ -3709,6 +3709,8 @@ describe('AssistantDatabase', () => {
|
||||
ALTER TABLE tasks DROP COLUMN current_user_message_id;
|
||||
ALTER TABLE tasks DROP COLUMN remote_recoverable;
|
||||
DROP INDEX task_events_remote_provenance_unique;
|
||||
DROP INDEX task_events_remote_task_idx;
|
||||
DROP INDEX task_events_lifecycle_idx;
|
||||
ALTER TABLE task_events DROP COLUMN remote_event_index;
|
||||
ALTER TABLE task_events DROP COLUMN remote_semantic_sequence;
|
||||
ALTER TABLE task_events DROP COLUMN remote_operation_id;
|
||||
|
||||
@@ -133,7 +133,7 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 51
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 52
|
||||
|
||||
export type RemoteTaskEventInput = {
|
||||
taskId: string
|
||||
@@ -11895,6 +11895,20 @@ export class AssistantDatabase {
|
||||
database.exec('PRAGMA user_version = 51; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
if (version.user_version < 52) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
// Execution stats poll every few seconds. Filtering lifecycle and remote
|
||||
// rows through task_events_task_idx reads every large stream payload row.
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS task_events_lifecycle_idx
|
||||
ON task_events(task_id, id) WHERE kind IN ('status', 'done', 'error');
|
||||
CREATE INDEX IF NOT EXISTS task_events_remote_task_idx
|
||||
ON task_events(task_id) WHERE remote_operation_id IS NOT NULL;
|
||||
`)
|
||||
database.exec('PRAGMA user_version = 52; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
}
|
||||
|
||||
supervisionReviewStore(): SupervisionReviewStore {
|
||||
|
||||
@@ -427,9 +427,12 @@ describe('execution duration evidence', () => {
|
||||
const pairMs = (performance.now() - start) / 5
|
||||
const query = prepare.mock.calls.find(([sql]) => sql.includes('WITH scoped AS MATERIALIZED'))![0]
|
||||
const plan = raw.prepare(`EXPLAIN QUERY PLAN ${query}`).all(projectId).map(row => row.detail as string)
|
||||
for (const alias of ['proof', 'remote', 'tail', 'previous', 'e']) {
|
||||
for (const alias of ['proof', 'tail', 'previous']) {
|
||||
expect(plan.some(detail => detail.includes(`SEARCH ${alias} USING INDEX task_events_task_idx`))).toBe(true)
|
||||
}
|
||||
// Filtering these through task_events_task_idx would read every payload row.
|
||||
expect(plan.some(detail => detail.includes('SEARCH remote USING INDEX task_events_remote_task_idx'))).toBe(true)
|
||||
expect(plan.some(detail => detail.includes('SEARCH e USING INDEX task_events_lifecycle_idx'))).toBe(true)
|
||||
// Malformed bodies make accidental JSON extraction fail deterministically.
|
||||
raw.prepare("UPDATE task_events SET payload_json = '{unparsed' WHERE task_id = ? AND kind IN ('text', 'tool')").run(id)
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
|
||||
@@ -34,6 +34,52 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('DesktopDiagnostics', () => {
|
||||
it('gates MCP metadata and discards unknown or unsafe fields', () => {
|
||||
const record = { timestamp: '2026-09-30T00:00:00Z', component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed' }
|
||||
const mcp = { phase: 'initialize', category: 'http-rejection', elapsedMs: 25, status: 406, correlationId: `sha256:${'a'.repeat(64)}`, token: 'private-token', headers: { authorization: 'private-token' } }
|
||||
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp })?.mcp).toEqual({ phase: 'initialize', category: 'http-rejection', elapsedMs: 25, status: 406, correlationId: mcp.correlationId })
|
||||
expect(normalizeDesktopDiagnosticRecord({ ...record, code: 'runtime.run.failed', mcp })).not.toHaveProperty('mcp')
|
||||
expect(normalizeDesktopDiagnosticRecord({ ...record, component: 'desktop', mcp })).not.toHaveProperty('mcp')
|
||||
for (const invalid of [{ phase: 'private-url' }, { category: 'private-body' }, { elapsedMs: -1 }, { elapsedMs: Infinity }, { elapsedMs: 1.5 }]) {
|
||||
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, ...invalid } })).not.toHaveProperty('mcp')
|
||||
}
|
||||
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, status: 999, correlationId: 'raw-token' } })?.mcp)
|
||||
.toEqual({ phase: 'initialize', category: 'http-rejection', elapsedMs: 25 })
|
||||
})
|
||||
|
||||
it('renormalizes stored MCP metadata during export after restart', async () => {
|
||||
const directory = await temporaryDirectory()
|
||||
const diagnostics = new DesktopDiagnostics(directory)
|
||||
const mcp = { phase: 'tool-discovery' as const, category: 'handler-failure' as const, elapsedMs: 42, correlationId: `sha256:${'b'.repeat(64)}` }
|
||||
await diagnostics.recordFailure({ component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed', error: new Error('private-error'), mcp })
|
||||
await diagnostics.dispose()
|
||||
const path = join(directory, 'desktop-diagnostics.ndjson')
|
||||
const stored = JSON.parse(await readFile(path, 'utf8'))
|
||||
expect(stored.mcp).toEqual(mcp)
|
||||
await writeFile(path, JSON.stringify({ ...stored, message: 'private-body', mcp: { ...mcp, token: 'private-token', url: 'private-url', stack: 'private-stack', status: 'private-status' } }) + '\n')
|
||||
const restarted = new DesktopDiagnostics(directory)
|
||||
try {
|
||||
const exported = (await restarted.exportRecent()).toString('utf8')
|
||||
expect(JSON.parse(exported).mcp).toEqual(mcp)
|
||||
expect(exported).not.toContain('private-')
|
||||
} finally { await restarted.dispose() }
|
||||
})
|
||||
|
||||
it.each(['connect', 'disconnect'])('allowlists runtime MCP %s attempts', (phase) => {
|
||||
const record = { timestamp: '2026-09-30T00:00:00Z', component: 'runtime', stage: phase, code: 'runtime.mcp.failed' }
|
||||
const mcp = { phase, category: 'transport-failure', elapsedMs: 1000, attempt: 2, kind: 'custom', correlationId: `sha256:${'c'.repeat(64)}` }
|
||||
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, error: 'private-error', token: 'private-token' } })?.mcp).toEqual(mcp)
|
||||
for (const category of ['initialization-failure', 'cleanup-failure', 'cancelled']) {
|
||||
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, category } })?.mcp?.category).toBe(category)
|
||||
}
|
||||
for (const attempt of [0, 3, 1.5, '1']) {
|
||||
const normalized = normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, attempt, kind: 'private-kind' } })?.mcp
|
||||
expect(normalized).not.toHaveProperty('attempt')
|
||||
expect(normalized).not.toHaveProperty('kind')
|
||||
expect(normalized?.correlationId).toBe(mcp.correlationId)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves renderer exit details on disk and in exports after restart', async () => {
|
||||
const directory = await temporaryDirectory()
|
||||
const diagnostics = new DesktopDiagnostics(directory)
|
||||
|
||||
@@ -35,6 +35,7 @@ const allowedCodes = new Set([
|
||||
'desktop.startup.failed',
|
||||
'runtime.operation.failed',
|
||||
'runtime.run.failed',
|
||||
'runtime.mcp.failed',
|
||||
'remote.connection.network',
|
||||
'remote.connection.host-invalidated',
|
||||
'remote.connection.host-identity',
|
||||
@@ -68,6 +69,35 @@ export type DesktopDiagnosticComponent =
|
||||
| 'runtime'
|
||||
| 'remote-agent'
|
||||
|
||||
export type DesktopMcpDiagnosticMetadata = Readonly<{
|
||||
phase: 'request' | 'authentication' | 'session' | 'initialize' | 'tool-discovery' | 'connect' | 'disconnect'
|
||||
category: 'http-rejection' | 'handler-failure' | 'transport-failure' | 'initialization-failure' | 'cleanup-failure' | 'cancelled'
|
||||
elapsedMs: number
|
||||
status?: number
|
||||
correlationId?: string
|
||||
attempt?: 1 | 2
|
||||
kind?: 'builtin' | 'custom'
|
||||
}>
|
||||
|
||||
function normalizeMcpMetadata(value: unknown): DesktopMcpDiagnosticMetadata | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const candidate = value as DesktopMcpDiagnosticMetadata
|
||||
if (!['request', 'authentication', 'session', 'initialize', 'tool-discovery', 'connect', 'disconnect'].includes(candidate.phase) ||
|
||||
!['http-rejection', 'handler-failure', 'transport-failure', 'initialization-failure', 'cleanup-failure', 'cancelled'].includes(candidate.category) ||
|
||||
!Number.isSafeInteger(candidate.elapsedMs) || candidate.elapsedMs < 0) return undefined
|
||||
return Object.freeze({
|
||||
phase: candidate.phase,
|
||||
category: candidate.category,
|
||||
elapsedMs: candidate.elapsedMs,
|
||||
...(candidate.attempt === 1 || candidate.attempt === 2 ? { attempt: candidate.attempt } : {}),
|
||||
...(candidate.kind === 'builtin' || candidate.kind === 'custom' ? { kind: candidate.kind } : {}),
|
||||
...(Number.isInteger(candidate.status) && candidate.status! >= 100 && candidate.status! <= 599
|
||||
? { status: candidate.status } : {}),
|
||||
...(typeof candidate.correlationId === 'string' && /^sha256:[a-f0-9]{64}$/.test(candidate.correlationId)
|
||||
? { correlationId: candidate.correlationId } : {})
|
||||
})
|
||||
}
|
||||
|
||||
export type DesktopDiagnosticFailure = Readonly<{
|
||||
component: DesktopDiagnosticComponent
|
||||
stage: string
|
||||
@@ -75,6 +105,7 @@ export type DesktopDiagnosticFailure = Readonly<{
|
||||
error: unknown
|
||||
reason?: string
|
||||
exitCode?: number
|
||||
mcp?: DesktopMcpDiagnosticMetadata
|
||||
}>
|
||||
|
||||
export type DesktopDiagnosticFailureObserver = (
|
||||
@@ -90,6 +121,7 @@ export type DesktopDiagnosticRecord = Readonly<{
|
||||
message: string
|
||||
reason?: string
|
||||
exitCode?: number
|
||||
mcp?: DesktopMcpDiagnosticMetadata
|
||||
}>
|
||||
|
||||
export type DesktopDiagnosticsOptions = Readonly<{
|
||||
@@ -178,6 +210,8 @@ export function normalizeDesktopDiagnosticRecord(
|
||||
const component = safeComponent(candidate.component)
|
||||
const stage = safeStage(candidate.stage)
|
||||
const code = safeCode(candidate.code)
|
||||
const mcp = component === 'runtime' && code === 'runtime.mcp.failed'
|
||||
? normalizeMcpMetadata(candidate.mcp) : undefined
|
||||
const rendererGone = component === 'desktop' && stage === 'renderer' &&
|
||||
code === 'desktop.renderer.gone'
|
||||
return Object.freeze({
|
||||
@@ -187,6 +221,7 @@ export function normalizeDesktopDiagnosticRecord(
|
||||
code,
|
||||
errorType: safeStoredErrorType(candidate.errorType),
|
||||
message: fixedMessage(component, stage),
|
||||
...(mcp ? { mcp } : {}),
|
||||
...(rendererGone && typeof candidate.reason === 'string' &&
|
||||
rendererExitReasons.has(candidate.reason) ? { reason: candidate.reason } : {}),
|
||||
...(rendererGone && Number.isSafeInteger(candidate.exitCode)
|
||||
@@ -266,7 +301,8 @@ export class DesktopDiagnostics {
|
||||
code: failure.code,
|
||||
errorType: safeErrorType(failure.error),
|
||||
reason: failure.reason,
|
||||
exitCode: failure.exitCode
|
||||
exitCode: failure.exitCode,
|
||||
mcp: failure.mcp
|
||||
})
|
||||
if (normalized === undefined) {
|
||||
return Promise.reject(
|
||||
|
||||
@@ -113,6 +113,23 @@ describe('FeedbackService', () => {
|
||||
code: 'runtime.run.failed',
|
||||
errorType: 'TimeoutError',
|
||||
message: 'password=hunter2'
|
||||
},
|
||||
{
|
||||
timestamp: '2026-08-25T01:02:04.000Z',
|
||||
component: 'runtime' as const,
|
||||
stage: 'connect',
|
||||
code: 'runtime.mcp.failed',
|
||||
errorType: 'Error',
|
||||
message: 'private-mcp-message',
|
||||
mcp: {
|
||||
phase: 'tool-discovery' as const,
|
||||
category: 'handler-failure' as const,
|
||||
elapsedMs: 123,
|
||||
correlationId: `sha256:${'a'.repeat(64)}`,
|
||||
token: 'private-mcp-token',
|
||||
headers: { authorization: 'private-mcp-auth' },
|
||||
url: 'private-mcp-url'
|
||||
}
|
||||
}
|
||||
])
|
||||
const fixture = service(
|
||||
@@ -146,6 +163,11 @@ describe('FeedbackService', () => {
|
||||
'{"timestamp":"2026-08-25T01:02:03.000Z","component":"runtime","stage":"run","code":"runtime.run.failed","errorType":"TimeoutError","message":"Runtime request failed"}'
|
||||
)
|
||||
expect(payload.description).not.toContain('hunter2')
|
||||
expect(payload.description).toContain(JSON.stringify({
|
||||
phase: 'tool-discovery', category: 'handler-failure', elapsedMs: 123,
|
||||
correlationId: `sha256:${'a'.repeat(64)}`
|
||||
}))
|
||||
expect(payload.description).not.toContain('private-mcp-')
|
||||
expect(payload.description).toMatch(
|
||||
/\n\[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_END\]$/u
|
||||
)
|
||||
|
||||
@@ -1030,6 +1030,7 @@ if (hasSingleInstanceLock) {
|
||||
const startupKnowledgeGateway = new KnowledgeMcpGateway(
|
||||
startupKnowledgeService,
|
||||
{
|
||||
observeFailure: observeDesktopFailure,
|
||||
storyGraphService: {
|
||||
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
||||
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
|
||||
@@ -1078,6 +1079,7 @@ if (hasSingleInstanceLock) {
|
||||
: Promise.resolve([])
|
||||
])
|
||||
return createAgentRuntime(defaultWorkspace, settings, {
|
||||
observeFailure: observeDesktopFailure,
|
||||
localRuntimeRegistry,
|
||||
skillInstructions: skillContext.instructions,
|
||||
skillPackages: skillContext.packages,
|
||||
@@ -1495,6 +1497,7 @@ if (hasSingleInstanceLock) {
|
||||
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
|
||||
openExternal: (url) => shell.openExternal(url),
|
||||
createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, {
|
||||
observeFailure: observeDesktopFailure,
|
||||
storyGraphService: {
|
||||
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
||||
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
|
||||
|
||||
@@ -1211,6 +1211,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
|
||||
let settings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -3291,6 +3292,7 @@ describe("App", () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -3395,6 +3397,7 @@ describe("App", () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -3411,6 +3414,7 @@ describe("App", () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -3513,6 +3517,7 @@ describe("App", () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -3529,6 +3534,7 @@ describe("App", () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -5922,6 +5928,7 @@ describe("App", () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -7835,6 +7842,77 @@ describe("App", () => {
|
||||
expect(localStorage.getItem("goodbuddy.appearance-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it('applies frosted glass only after saving and removes it when disabled', async () => {
|
||||
const updates = api.updates!
|
||||
const { container, unmount } = render(<App />)
|
||||
const shell = container.querySelector('.app-shell')
|
||||
expect(shell).not.toHaveAttribute('data-frosted-glass')
|
||||
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }))
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '外观' }))
|
||||
const toggle = screen.getByRole('switch', { name: '透明磨砂特效' })
|
||||
expect(toggle.closest('.toggle-row')?.firstElementChild).toHaveTextContent('透明磨砂特效')
|
||||
expect(toggle.closest('.toggle-row')?.lastElementChild).toBe(toggle)
|
||||
await waitFor(() => expect(toggle).toBeEnabled())
|
||||
const saved = { ...await updates.getSettings(), transparentFrostedEffectEnabled: true }
|
||||
let resolveSave!: (settings: ApplicationSettings) => void
|
||||
vi.mocked(updates.updateSettings).mockImplementationOnce(() => new Promise(resolve => { resolveSave = resolve }))
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toBeDisabled()
|
||||
expect(toggle).not.toBeChecked()
|
||||
expect(shell).not.toHaveAttribute('data-frosted-glass')
|
||||
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
|
||||
expect(updates.updateSettings).toHaveBeenLastCalledWith({ transparentFrostedEffectEnabled: true })
|
||||
await act(async () => resolveSave(saved))
|
||||
expect(toggle).toBeChecked()
|
||||
expect(toggle).toBeEnabled()
|
||||
expect(shell).toHaveAttribute('data-frosted-glass', 'true')
|
||||
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => {
|
||||
expect(toggle).not.toBeChecked()
|
||||
expect(toggle).toBeEnabled()
|
||||
expect(shell).not.toHaveAttribute('data-frosted-glass')
|
||||
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
|
||||
})
|
||||
expect(updates.updateSettings).toHaveBeenLastCalledWith({ transparentFrostedEffectEnabled: false })
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => expect(shell).toHaveAttribute('data-frosted-glass', 'true'))
|
||||
unmount()
|
||||
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
|
||||
const reloaded = render(<App />)
|
||||
await waitFor(() => expect(reloaded.container.querySelector('.app-shell')).toHaveAttribute('data-frosted-glass', 'true'))
|
||||
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
|
||||
})
|
||||
|
||||
it('retains confirmed frosted glass after a failed save and allows rereading locked settings', async () => {
|
||||
const updates = api.updates!
|
||||
await updates.updateSettings({ transparentFrostedEffectEnabled: true })
|
||||
const { container } = render(<App />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }))
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '外观' }))
|
||||
const toggle = screen.getByRole('switch', { name: '透明磨砂特效' })
|
||||
await waitFor(() => {
|
||||
expect(toggle).toBeEnabled()
|
||||
expect(toggle).toBeChecked()
|
||||
})
|
||||
vi.mocked(updates.updateSettings).mockRejectedValueOnce(new Error('response lost'))
|
||||
vi.mocked(updates.getSettings).mockRejectedValueOnce(new Error('read unavailable'))
|
||||
fireEvent.click(toggle)
|
||||
await screen.findByText('无法确认保存结果,请重新读取后再修改。')
|
||||
expect(toggle).toBeChecked()
|
||||
expect(toggle).toBeDisabled()
|
||||
expect(container.querySelector('.app-shell')).toHaveAttribute('data-frosted-glass', 'true')
|
||||
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(toggle).toBeEnabled())
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => {
|
||||
expect(toggle).not.toBeChecked()
|
||||
expect(container.querySelector('.app-shell')).not.toHaveAttribute('data-frosted-glass')
|
||||
})
|
||||
})
|
||||
|
||||
it("loads token usage in activity and refreshes it when a run finishes", async () => {
|
||||
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
|
||||
totals: {
|
||||
@@ -13848,6 +13926,7 @@ describe("App", () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -13864,6 +13943,7 @@ describe("App", () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -13917,6 +13997,7 @@ describe("App", () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -13977,6 +14058,7 @@ describe("App", () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -14014,6 +14096,7 @@ describe("App", () => {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -14030,6 +14113,7 @@ describe("App", () => {
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -14092,6 +14176,7 @@ describe("App", () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -14151,6 +14236,7 @@ describe("App", () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
|
||||
@@ -1996,6 +1996,14 @@ function App(): React.JSX.Element {
|
||||
const [applicationMenuOpen, setApplicationMenuOpen] = useState(false);
|
||||
const applicationMenuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [applicationSettings, setApplicationSettings] = useState<ApplicationSettings>();
|
||||
useLayoutEffect(() => {
|
||||
// FloatingPortal surfaces live outside app-shell, including in the top layer.
|
||||
delete document.documentElement.dataset.frostedGlass;
|
||||
if (applicationSettings?.transparentFrostedEffectEnabled) {
|
||||
document.documentElement.dataset.frostedGlass = 'true';
|
||||
}
|
||||
return () => { delete document.documentElement.dataset.frostedGlass; };
|
||||
}, [applicationSettings?.transparentFrostedEffectEnabled]);
|
||||
const [applicationSettingsPending, setApplicationSettingsPending] = useState(false);
|
||||
const [applicationSettingsUnconfirmed, setApplicationSettingsUnconfirmed] = useState(false);
|
||||
const applicationSettingsPendingRef = useRef(0);
|
||||
@@ -8559,7 +8567,7 @@ function App(): React.JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app-shell" data-frosted-glass={applicationSettings?.transparentFrostedEffectEnabled ? 'true' : undefined}>
|
||||
<DocumentConversationContext value={{
|
||||
activeId: activeConversation?.remote ? undefined : activeId,
|
||||
create: () => new Promise((resolve, reject) => {
|
||||
@@ -10971,6 +10979,12 @@ function App(): React.JSX.Element {
|
||||
<Suspense fallback={null}>
|
||||
<SettingsPanel
|
||||
appearanceTheme={appearanceTheme}
|
||||
transparentFrostedEffectEnabled={applicationSettings?.transparentFrostedEffectEnabled ?? false}
|
||||
applicationSettingsPending={applicationSettingsPending}
|
||||
applicationSettingsLocked={applicationSettingsUnconfirmed || !applicationSettings}
|
||||
applicationSettingsError={applicationSettingsError}
|
||||
onTransparentFrostedEffectEnabledChange={(enabled) => updateApplicationSettings({ transparentFrostedEffectEnabled: enabled })}
|
||||
onRetryApplicationSettings={() => void reloadApplicationSettings()}
|
||||
brandingFallbackLogo={
|
||||
resolvedAppearanceTheme === "dark"
|
||||
? goodbuddyDarkIcon
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
const { t } = useTranslation("app");
|
||||
const headingId = `chat-heading-${conversation.id}`;
|
||||
const scrollRef = useRef<HTMLElement>(null);
|
||||
const contextRef = useRef<HTMLDivElement>(null);
|
||||
const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true);
|
||||
const latestScrollSnapshotRef = useRef(scrollSnapshot);
|
||||
const restorePendingRef = useRef(true);
|
||||
@@ -130,6 +131,29 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
);
|
||||
const hiddenMessageCount = visibleMessageStartIndex;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const context = contextRef.current;
|
||||
const scrollContainer = scrollRef.current;
|
||||
const content = scrollContainer?.querySelector<HTMLElement>('.chat-content');
|
||||
if (!active || !context || !scrollContainer || !content) return;
|
||||
const measure = (): void => {
|
||||
// The fixed context strip also needs space inside the scrolling content.
|
||||
context.parentElement?.style.setProperty(
|
||||
'--chat-context-height', `${context.getBoundingClientRect().height}px`,
|
||||
);
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'function') return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
measure();
|
||||
if (restorePendingRef.current || !pinnedToBottomRef.current) return;
|
||||
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'auto' });
|
||||
});
|
||||
observer.observe(context);
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || noteMessageNavigation?.conversationId !== conversation.id) return;
|
||||
const element = messageArticleRefs.current.get(noteMessageNavigation.messageId);
|
||||
@@ -232,29 +256,6 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
}
|
||||
}, [active, conversation.messages, scrollSnapshot, visibleMessageCount]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!active || typeof ResizeObserver !== "function") {
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollRef.current;
|
||||
const messageList =
|
||||
scrollContainer?.querySelector<HTMLElement>(".message-list");
|
||||
if (!scrollContainer || !messageList) {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (restorePendingRef.current || !pinnedToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: "auto",
|
||||
});
|
||||
});
|
||||
observer.observe(messageList);
|
||||
return () => observer.disconnect();
|
||||
}, [active]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previous = prependScrollPositionRef.current;
|
||||
if (!previous) {
|
||||
@@ -330,7 +331,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
<h1 className="sr-only" id={headingId}>
|
||||
{t("chat.heading")}
|
||||
</h1>
|
||||
{taskStrip}
|
||||
<div className="chat-history-context" ref={contextRef}>{taskStrip}</div>
|
||||
<section
|
||||
aria-labelledby={headingId}
|
||||
className="chat"
|
||||
@@ -338,59 +339,61 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
onScroll={updateScrollPosition}
|
||||
ref={handleScrollRef}
|
||||
>
|
||||
{isUnusedConversation(conversation) && (
|
||||
<div className="welcome">
|
||||
<div className="welcome__badge">
|
||||
<Sparkles size={18} />
|
||||
<div className="chat-content">
|
||||
{isUnusedConversation(conversation) && (
|
||||
<div className="welcome">
|
||||
<div className="welcome__badge">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<h2>{t("chat.welcome.title")}</h2>
|
||||
<div className="quick-actions">
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
key={action.title}
|
||||
onClick={() => onSetInput(action.prompt)}
|
||||
type="button"
|
||||
>
|
||||
<span className="quick-actions__icon">
|
||||
<FileText size={17} />
|
||||
</span>
|
||||
<strong>{action.title}</strong>
|
||||
<small>{action.description}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<h2>{t("chat.welcome.title")}</h2>
|
||||
<div className="quick-actions">
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
key={action.title}
|
||||
onClick={() => onSetInput(action.prompt)}
|
||||
type="button"
|
||||
>
|
||||
<span className="quick-actions__icon">
|
||||
<FileText size={17} />
|
||||
</span>
|
||||
<strong>{action.title}</strong>
|
||||
<small>{action.description}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ChatTimeline
|
||||
onOpenImageModelSettings={onOpenImageModelSettings}
|
||||
onReselectImageSources={onReselectImageSources}
|
||||
onEditImage={onEditImage}
|
||||
artifactById={artifactById}
|
||||
conversationId={conversation.id}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
isUnusedConversation={isUnusedConversation(conversation)}
|
||||
locale={locale}
|
||||
messages={visibleMessages}
|
||||
messageStartIndex={visibleMessageStartIndex}
|
||||
onArticleRef={handleArticleRef}
|
||||
onCopyMessage={onCopyMessage}
|
||||
onAddToNote={onAddToNote}
|
||||
onDownloadImage={onDownloadImage}
|
||||
onOpenCitationContext={onOpenCitationContext}
|
||||
onOpenCitationSource={onOpenCitationSource}
|
||||
onOpenImage={onOpenImage}
|
||||
onRespondApproval={onRespondApproval}
|
||||
onRespondQuestion={onRespondQuestion}
|
||||
onRetry={onRetry}
|
||||
onRevealEarlier={revealEarlierMessages}
|
||||
renderAssistantHtml={conversationHtmlRenderingEnabled}
|
||||
retryContent={
|
||||
conversation.messages.at(-2)?.role === "user"
|
||||
? conversation.messages.at(-2)?.content
|
||||
: undefined
|
||||
}
|
||||
totalMessageCount={conversation.messages.length}
|
||||
/>
|
||||
)}
|
||||
<ChatTimeline
|
||||
onOpenImageModelSettings={onOpenImageModelSettings}
|
||||
onReselectImageSources={onReselectImageSources}
|
||||
onEditImage={onEditImage}
|
||||
artifactById={artifactById}
|
||||
conversationId={conversation.id}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
isUnusedConversation={isUnusedConversation(conversation)}
|
||||
locale={locale}
|
||||
messages={visibleMessages}
|
||||
messageStartIndex={visibleMessageStartIndex}
|
||||
onArticleRef={handleArticleRef}
|
||||
onCopyMessage={onCopyMessage}
|
||||
onAddToNote={onAddToNote}
|
||||
onDownloadImage={onDownloadImage}
|
||||
onOpenCitationContext={onOpenCitationContext}
|
||||
onOpenCitationSource={onOpenCitationSource}
|
||||
onOpenImage={onOpenImage}
|
||||
onRespondApproval={onRespondApproval}
|
||||
onRespondQuestion={onRespondQuestion}
|
||||
onRetry={onRetry}
|
||||
onRevealEarlier={revealEarlierMessages}
|
||||
renderAssistantHtml={conversationHtmlRenderingEnabled}
|
||||
retryContent={
|
||||
conversation.messages.at(-2)?.role === "user"
|
||||
? conversation.messages.at(-2)?.content
|
||||
: undefined
|
||||
}
|
||||
totalMessageCount={conversation.messages.length}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{active && showScrollToBottom && (
|
||||
<button
|
||||
|
||||
@@ -74,6 +74,7 @@ const todo: MagicTodoItem = {
|
||||
const otherTodo: MagicTodoItem = { ...todo, id: '00000000-0000-4000-8000-000000000604', noteId: secondNoteId, noteTitle: second.title, title: '准备演示' }
|
||||
const settings: ApplicationSettings = {
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation,
|
||||
localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
|
||||
|
||||
@@ -520,6 +520,7 @@ const removeEmbeddingModel = vi.fn(
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -814,6 +815,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
applicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
@@ -1549,6 +1551,36 @@ describe('SettingsPanel runtime files', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh-CN', '外观', '透明磨砂特效', '应用于顶栏和展开的运行待办卡片,保存后立即生效。'],
|
||||
['en-US', 'Appearance', 'Frosted glass effect', 'Applies to the top bar and expanded runtime checklist. Takes effect as soon as it is saved.']
|
||||
] as const)('exposes the frosted glass Switch under Appearance in %s', async (locale, category, label, description) => {
|
||||
await changeUiLocale(locale)
|
||||
const onChange = vi.fn(async () => true)
|
||||
const props = {
|
||||
...heartbeatSettingsProps,
|
||||
open: true,
|
||||
onClearLocalData: vi.fn(async () => {}),
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn(),
|
||||
onTransparentFrostedEffectEnabledChange: onChange
|
||||
}
|
||||
const { rerender } = render(<SettingsPanel {...props} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: category }))
|
||||
const toggle = screen.getByRole('switch', { name: label })
|
||||
expect(toggle).not.toBeChecked()
|
||||
expect(toggle).toHaveAccessibleDescription(description)
|
||||
expect(toggle.closest('label')).toHaveClass('toggle-row')
|
||||
fireEvent.click(toggle)
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith(true)
|
||||
rerender(<SettingsPanel {...props} transparentFrostedEffectEnabled applicationSettingsPending />)
|
||||
expect(toggle).toBeChecked()
|
||||
expect(toggle).toBeDisabled()
|
||||
rerender(<SettingsPanel {...props} transparentFrostedEffectEnabled />)
|
||||
fireEvent.click(toggle)
|
||||
expect(onChange).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh-CN', '平台功能', '桌面通知'],
|
||||
['en-US', 'Platform features', 'Desktop notifications']
|
||||
|
||||
@@ -235,6 +235,12 @@ type SettingsPanelProps = {
|
||||
onExpertsChanged?: (experts: AssistantExpert[]) => void
|
||||
onClearLocalData: () => Promise<void>
|
||||
appearanceTheme?: AppearanceTheme
|
||||
transparentFrostedEffectEnabled?: boolean
|
||||
applicationSettingsPending?: boolean
|
||||
applicationSettingsLocked?: boolean
|
||||
applicationSettingsError?: string
|
||||
onTransparentFrostedEffectEnabledChange?: (enabled: boolean) => Promise<boolean>
|
||||
onRetryApplicationSettings?: () => void
|
||||
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
||||
brandingPreferences?: BrandingPreferences
|
||||
brandingFallbackLogo?: string
|
||||
@@ -648,6 +654,12 @@ export function SettingsPanel({
|
||||
onClearLocalData,
|
||||
onExpertsChanged = () => {},
|
||||
appearanceTheme = 'system',
|
||||
transparentFrostedEffectEnabled = false,
|
||||
applicationSettingsPending = false,
|
||||
applicationSettingsLocked = false,
|
||||
applicationSettingsError,
|
||||
onTransparentFrostedEffectEnabledChange,
|
||||
onRetryApplicationSettings,
|
||||
onAppearanceThemeChange = () => {},
|
||||
brandingPreferences = defaultBrandingPreferences,
|
||||
brandingFallbackLogo,
|
||||
@@ -2340,6 +2352,32 @@ export function SettingsPanel({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-section">
|
||||
<label className="toggle-row">
|
||||
<span>{t('appearance.frostedGlass.label')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
checked={transparentFrostedEffectEnabled}
|
||||
disabled={applicationSettingsPending || applicationSettingsLocked || !onTransparentFrostedEffectEnabledChange}
|
||||
aria-describedby="frosted-glass-description"
|
||||
onChange={(event) => {
|
||||
void onTransparentFrostedEffectEnabledChange?.(event.target.checked)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p id="frosted-glass-description" className="settings-panel__description">
|
||||
{t('appearance.frostedGlass.description')}
|
||||
</p>
|
||||
{applicationSettingsError && (
|
||||
<div role="alert">
|
||||
<p>{applicationSettingsError}</p>
|
||||
<button className="secondary-button" disabled={applicationSettingsPending} onClick={onRetryApplicationSettings} type="button">
|
||||
{t('appearance.frostedGlass.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-section appearance-settings">
|
||||
<div className="settings-section__title">
|
||||
<span className="inline-help-label">
|
||||
|
||||
@@ -24,6 +24,7 @@ describe('UpdateSettingsSection', () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
|
||||
@@ -105,6 +105,9 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(toggleRowStyles).toMatch(/align-items:\s*center;/u)
|
||||
expect(toggleRowStyles).not.toMatch(/border-top:/u)
|
||||
expect(toggleRowStyles).not.toMatch(/padding-top:/u)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.toggle-row > input\[role='switch'\]\s*\{[^}]*order:\s*1;[^}]*margin-inline-start:\s*auto;/u
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Platform Features tabs visible in scrollable Settings', () => {
|
||||
@@ -558,7 +561,7 @@ describe('WorkspacePrimitives', () => {
|
||||
/\.settings-backdrop \.settings-tabs button strong\s*\{[^}]*font-size:\s*var\(--font-body\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.settings-backdrop \.settings-tabs button small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
|
||||
/\.settings-tabs__group-label\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -891,6 +891,11 @@ export const settings = {
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
frostedGlass: {
|
||||
label: 'Frosted glass effect',
|
||||
description: 'Applies to the top bar and expanded runtime checklist. Takes effect as soon as it is saved.',
|
||||
retry: 'Retry'
|
||||
},
|
||||
theme: {
|
||||
title: 'Interface theme',
|
||||
description: 'Applies immediately and is saved on this device',
|
||||
|
||||
@@ -895,7 +895,7 @@ export const settingsSections = {
|
||||
diagnostics: {
|
||||
label: 'Attach recent desktop diagnostics',
|
||||
description:
|
||||
'Off by default. When selected, this adds only timestamps, components, stages, stable error codes, error types, and fixed short messages from recent desktop diagnostics. It does not include conversations, prompts, credentials, file contents, paths, raw provider responses, or remote Agent logs.'
|
||||
'Off by default. When selected, this adds timestamps, components, stages, error codes, error types, and fixed short messages from recent desktop diagnostics, plus tool connection failure categories, timing, HTTP status, attempt counts, tool kinds, and anonymous correlation IDs. It does not include conversations, prompts, credentials, file contents, paths, raw provider responses, or remote Agent logs.'
|
||||
},
|
||||
screenshot: {
|
||||
title: 'Screenshot (optional)',
|
||||
|
||||
@@ -826,6 +826,11 @@ export const settings = {
|
||||
}
|
||||
},
|
||||
appearance: {
|
||||
frostedGlass: {
|
||||
label: '透明磨砂特效',
|
||||
description: '应用于顶栏和展开的运行待办卡片,保存后立即生效。',
|
||||
retry: '重试'
|
||||
},
|
||||
theme: {
|
||||
title: '界面主题',
|
||||
description: '选择后立即应用,并保存在此设备',
|
||||
|
||||
@@ -840,7 +840,7 @@ export const settingsSections = {
|
||||
diagnostics: {
|
||||
label: '附加最近桌面诊断记录',
|
||||
description:
|
||||
'默认关闭。选中后仅附加最近桌面诊断的时间、组件、阶段、稳定错误码、错误类型和固定短消息;不包含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。'
|
||||
'默认关闭。选中后附加最近桌面诊断的时间、组件、阶段、错误码、错误类型和固定短消息,以及工具连接失败的类别、耗时、HTTP 状态、尝试次数、工具类型和匿名关联标识;不包含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。'
|
||||
},
|
||||
screenshot: {
|
||||
title: '截图(可选)',
|
||||
|
||||
@@ -74,6 +74,12 @@
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
.runtime-checklist__content ol { margin: 0; padding: var(--space-2); list-style: none; }
|
||||
:root[data-frosted-glass='true'] .runtime-checklist__content {
|
||||
background: var(--surface-glass-panel);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.runtime-checklist__content li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
--surface-raised: #fff;
|
||||
--surface-subtle: #f4f8fb;
|
||||
--surface-muted: #e5edf3;
|
||||
--surface-glass: rgb(255 255 255 / 65%);
|
||||
--surface-glass-panel: rgb(255 255 255 / 75%);
|
||||
--border-glass: rgb(116 138 159 / 16%);
|
||||
--text-primary: #17263a;
|
||||
--text-secondary: #4f6278;
|
||||
--text-muted: #60748a;
|
||||
@@ -4591,6 +4594,30 @@ textarea:focus-visible {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.app-shell[data-frosted-glass='true'] .topbar {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
background: var(--surface-glass);
|
||||
border-bottom-color: var(--border-glass);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
/* Extend only the active chat; the workbar and other routes keep their grid row. */
|
||||
.app-shell[data-frosted-glass='true'] .workspace:has(> [data-route='chat']:not([hidden]) .chat-history-pane[data-active='true']) {
|
||||
margin-top: -58px;
|
||||
}
|
||||
|
||||
.app-shell[data-frosted-glass='true'] .chat-history-context {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 58px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-shell[data-frosted-glass='true'] .chat-content {
|
||||
padding-top: calc(58px + var(--chat-context-height, 0px) + var(--page-gutter));
|
||||
}
|
||||
|
||||
/*
|
||||
* Overlays are portalled to document.body and cover the whole window, so they
|
||||
* paint over the .brand and .topbar drag regions. Without no-drag the native
|
||||
@@ -6216,10 +6243,16 @@ button > svg {
|
||||
}
|
||||
|
||||
.chat-history-pane {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-history-context {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-history-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -6253,6 +6286,14 @@ button > svg {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.chat-history-pane > .chat {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
padding: var(--page-gutter) 0 10px;
|
||||
}
|
||||
|
||||
.chat-scroll-to-bottom {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
@@ -6383,7 +6424,7 @@ button > svg {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat > .welcome,
|
||||
.chat-content > .welcome,
|
||||
.message-list {
|
||||
/* Keep the scrollbar inside the outer gutter without adding trailing scroll padding. */
|
||||
width: calc(100cqw - 2 * var(--chat-inline-padding));
|
||||
@@ -17313,6 +17354,9 @@ textarea.field-control {
|
||||
--surface-raised: #0d1d2c;
|
||||
--surface-subtle: #12283a;
|
||||
--surface-muted: #1a3448;
|
||||
--surface-glass: rgb(7 16 31 / 70%);
|
||||
--surface-glass-panel: rgb(7 16 31 / 75%);
|
||||
--border-glass: rgb(143 166 185 / 18%);
|
||||
--text-primary: #edf7ff;
|
||||
--text-secondary: #b5c6d5;
|
||||
--text-muted: #8fa6b9;
|
||||
|
||||
@@ -59,6 +59,7 @@ const applicationPreferencesSchema = z
|
||||
.object({
|
||||
checkUpdatesOnStartup: z.boolean(),
|
||||
desktopNotificationsEnabled: z.boolean().default(true),
|
||||
transparentFrostedEffectEnabled: z.boolean().default(false),
|
||||
updateSource: updateSourceSchema,
|
||||
modelDownloadSource: modelDownloadSourceSchema,
|
||||
localToolEnvironment: localToolEnvironmentSettingsSchema,
|
||||
@@ -90,6 +91,7 @@ export const applicationSettingsUpdateSchema = applicationPreferencesSchema
|
||||
.extend({
|
||||
applicationNavigation: applicationNavigationSchema.optional(),
|
||||
desktopNotificationsEnabled: z.boolean().optional(),
|
||||
transparentFrostedEffectEnabled: z.boolean().optional(),
|
||||
localInferenceEnabled: z.boolean().optional(),
|
||||
magicNotesEnabled: z.boolean().optional(),
|
||||
heartbeatEnabled: z.boolean().optional(),
|
||||
|
||||
@@ -153,6 +153,7 @@ describe('GoodBuddy configuration contracts', () => {
|
||||
application: {
|
||||
checkUpdatesOnStartup: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
transparentFrostedEffectEnabled: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createServer } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('scrolls real chat messages behind frosted glass while preserving controls, anchors and other routes', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-frosted-glass-'))
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||
plugins: [react(), { name: 'frosted-glass', configureServer(server) {
|
||||
server.middlewares.use('/glass.html', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html')
|
||||
response.end(await server.transformIndexHtml('/glass.html', `<!doctype html>
|
||||
<html><body><div id="root"></div>
|
||||
<script type="module" src="/tests/support/frosted-glass-regression.tsx"></script></body></html>`))
|
||||
})
|
||||
} }],
|
||||
optimizeDeps: { entries: ['tests/support/frosted-glass-regression.tsx'] },
|
||||
server: { host: '127.0.0.1', port: 0, watch: null }
|
||||
})
|
||||
try {
|
||||
await server.listen()
|
||||
const driver = join(directory, 'driver.mjs')
|
||||
await copyFile(resolve('tests/support/frosted-glass-driver.mjs'), driver)
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GB_GLASS_DIRECTORY: directory,
|
||||
GB_GLASS_URL: server.resolvedUrls!.local[0] + 'glass.html' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let output = ''
|
||||
child.stdout.on('data', data => { output += data })
|
||||
child.stderr.on('data', data => { output += data })
|
||||
const timeout = setTimeout(() => child.kill(), 60000)
|
||||
try {
|
||||
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
|
||||
expect(code, output).toBe(0)
|
||||
process.stdout.write(output.split(/\r?\n/).filter(line => line.startsWith('FrostedGlass')).join('\n') + '\n')
|
||||
} finally { clearTimeout(timeout) }
|
||||
} finally {
|
||||
await server.close()
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
}
|
||||
}, 90000)
|
||||
@@ -0,0 +1,225 @@
|
||||
import { app, BrowserWindow, screen } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
import { createRequire } from 'node:module'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
app.setPath('userData', join(process.env.GB_GLASS_DIRECTORY, 'profile'))
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ show: true, frame: false, width: 1280, height: 800, useContentSize: true,
|
||||
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, spellcheck: false, backgroundThrottling: false } })
|
||||
win.webContents.on('console-message', event => console.log('Renderer:', event.message))
|
||||
win.webContents.on('preload-error', (_event, _path, error) => console.error(error))
|
||||
await win.loadURL(process.env.GB_GLASS_URL)
|
||||
await win.webContents.executeJavaScript(`new Promise((resolve, reject) => {
|
||||
setTimeout(() => reject(new Error('Fixture did not mount: ' + document.body.innerText)), 15000);
|
||||
const ready = () => {
|
||||
const toggle = document.querySelector('.runtime-checklist__toggle');
|
||||
if (!toggle) return requestAnimationFrame(ready);
|
||||
toggle.click();
|
||||
const expanded = () => document.querySelector('.runtime-checklist__content')
|
||||
? resolve(true) : requestAnimationFrame(expanded);
|
||||
expanded();
|
||||
};
|
||||
ready();
|
||||
})`)
|
||||
await win.webContents.executeJavaScript('document.fonts.ready.then(() => true)')
|
||||
let baselineLayout
|
||||
for (const theme of ['light', 'dark']) {
|
||||
let off
|
||||
for (const state of [null, 'true', null, 'false']) {
|
||||
const label = `${theme}/${state ?? 'absent'}`
|
||||
const result = await win.webContents.executeJavaScript(`(() => {
|
||||
document.documentElement.dataset.theme = ${JSON.stringify(theme)};
|
||||
const shell = document.querySelector('.app-shell');
|
||||
const state = ${JSON.stringify(state)};
|
||||
for (const node of [shell, document.documentElement]) {
|
||||
if (state === null) node.removeAttribute('data-frosted-glass');
|
||||
else node.dataset.frostedGlass = state;
|
||||
}
|
||||
const panel = document.querySelector('.runtime-checklist__content');
|
||||
if (shell.contains(panel) || !panel.closest('.floating-portal').matches(':popover-open'))
|
||||
throw new Error('Checklist must render outside app-shell in the top layer');
|
||||
const selectors = ['.app-shell', '.app-frame', '.topbar', '.workspace',
|
||||
'.runtime-checklist', '.runtime-checklist__content', '[data-ordinary-menu]',
|
||||
'.topbar button', '.topbar input', '.topbar select', '.topbar__actions', '.window-controls', '.window-control'];
|
||||
return Object.fromEntries(selectors.map(selector => {
|
||||
const e = document.querySelector(selector), s = getComputedStyle(e), r = e.getBoundingClientRect();
|
||||
return [selector, { background: s.backgroundColor, image: s.backgroundImage,
|
||||
blur: s.backdropFilter, color: s.color, opacity: s.opacity, region: s.getPropertyValue('-webkit-app-region'),
|
||||
layout: [r.x, r.y, r.width, r.height, s.display, s.gridTemplateRows, s.padding, s.gap] }];
|
||||
}));
|
||||
})()`)
|
||||
const topbar = result['.topbar']
|
||||
assert.equal(topbar.region, 'drag', label)
|
||||
for (const selector of ['.topbar button', '.topbar input', '.topbar select', '.topbar__actions', '.window-controls', '.window-control']) {
|
||||
assert.equal(result[selector].region, 'no-drag', `${label} ${selector}`)
|
||||
}
|
||||
const layout = Object.fromEntries(Object.entries(result).filter(([selector]) => selector !== '.workspace').map(([selector, value]) => [selector, value.layout]))
|
||||
baselineLayout ??= layout
|
||||
assert.deepEqual(layout, baselineLayout, `${label} layout`)
|
||||
assert.equal(topbar.layout[3], 58, `${label} topbar height`)
|
||||
assert.ok(result['.workspace'].layout[3] > 0, `${label} workspace is visible`)
|
||||
const alpha = Number(topbar.background.match(/^rgba?\((.*)\)$/)[1].split(',')[3] ?? 1)
|
||||
if (state === 'true') {
|
||||
assert.equal(alpha, theme === 'light' ? 0.65 : 0.7, `${label} alpha`)
|
||||
assert.equal(topbar.background, theme === 'light' ? 'rgba(255, 255, 255, 0.65)' : 'rgba(7, 16, 31, 0.7)', label)
|
||||
assert.equal(topbar.blur, 'blur(18px)', label)
|
||||
assert.equal(result['.app-frame'].image, 'none', label)
|
||||
assert.equal(result['.workspace'].layout[1], off['.workspace'].layout[1] - 58, `${label} chat extends behind topbar`)
|
||||
assert.deepEqual(result['.runtime-checklist'], off['.runtime-checklist'], `${label} solid strip unchanged`)
|
||||
assert.deepEqual(result['[data-ordinary-menu]'], off['[data-ordinary-menu]'], `${label} other menus unchanged`)
|
||||
const panel = result['.runtime-checklist__content']
|
||||
assert.equal(panel.background, theme === 'light' ? 'rgba(255, 255, 255, 0.75)' : 'rgba(7, 16, 31, 0.75)', label)
|
||||
assert.equal(panel.blur, 'blur(18px)', label)
|
||||
assert.equal(panel.opacity, '1', `${label} text stays opaque`)
|
||||
const rgb = value => value.match(/[\d.]+/g).map(Number)
|
||||
const luminance = values => values.map(value => {
|
||||
const channel = value / 255
|
||||
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||
}).reduce((sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index], 0)
|
||||
const text = luminance(rgb(panel.color))
|
||||
const background = luminance(rgb(panel.background).slice(0, 3).map(value => value * 0.75 + (theme === 'light' ? 0 : 255) * 0.25))
|
||||
assert.ok((Math.max(text, background) + 0.05) / (Math.min(text, background) + 0.05) >= 4.5, `${label} checklist text contrast`)
|
||||
} else {
|
||||
assert.equal(alpha, 1, `${label} opaque background`)
|
||||
assert.equal(topbar.blur, 'none', label)
|
||||
assert.equal(result['.app-frame'].image, 'none', label)
|
||||
off ??= result
|
||||
assert.deepEqual(result, off, `${label} restores original styles`)
|
||||
}
|
||||
console.log(`FrostedGlass ${label}: alpha=${alpha}, backdrop-filter=${topbar.blur}, drag regions and surrounding layout preserved`)
|
||||
}
|
||||
}
|
||||
|
||||
const evaluate = source => win.webContents.executeJavaScript(source)
|
||||
const waitFor = source => evaluate(`new Promise((resolve, reject) => {
|
||||
const deadline = performance.now() + 5000;
|
||||
const check = () => { if (${source}) resolve(true);
|
||||
else if (performance.now() > deadline) reject(new Error(${JSON.stringify(source)}));
|
||||
else requestAnimationFrame(check); }; check(); })`)
|
||||
const click = async selector => {
|
||||
const point = await evaluate(`(() => { const e = document.querySelector(${JSON.stringify(selector)});
|
||||
const r = e.getBoundingClientRect(); const x = r.x + r.width / 2, y = r.y + r.height / 2;
|
||||
if (!e.contains(document.elementFromPoint(x, y))) throw new Error('Click target obscured: ' + ${JSON.stringify(selector)});
|
||||
return { x: Math.round(x), y: Math.round(y) }; })()`)
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
|
||||
}
|
||||
win.focus()
|
||||
console.log('FrostedGlass starting native interaction checks')
|
||||
await waitFor('document.hasFocus()')
|
||||
await click('.runtime-checklist__toggle')
|
||||
await waitFor("!document.querySelector('.runtime-checklist__content')")
|
||||
await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 400`)
|
||||
await waitFor("document.querySelector('.chat-scroll-to-bottom') !== null")
|
||||
const toggleAnchor = await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article').getBoundingClientRect().top`)
|
||||
for (const enabled of [true, false]) {
|
||||
await evaluate(`for (const e of [document.documentElement, document.querySelector('.app-shell')]) e.dataset.frostedGlass = '${enabled}'`)
|
||||
await evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
||||
assert.equal(await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article').getBoundingClientRect().top`), toggleAnchor, 'toggle preserves reader position')
|
||||
}
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await evaluate(`document.documentElement.dataset.theme = '${theme}'`)
|
||||
for (const enabled of [false, true, false]) {
|
||||
await evaluate(`for (const e of [document.documentElement, document.querySelector('.app-shell')]) e.dataset.frostedGlass = '${enabled}';
|
||||
document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 0;`)
|
||||
await waitFor("document.querySelector('.chat-scroll-to-bottom') !== null")
|
||||
const geometry = await evaluate(`(() => {
|
||||
const rect = selector => { const r = document.querySelector(selector).getBoundingClientRect(); return { top:r.top, bottom:r.bottom, height:r.height }; };
|
||||
return { topbar:rect('.topbar'), viewport:rect('.chat-history-pane[data-active="true"] .chat'),
|
||||
first:rect('.chat-history-pane[data-active="true"] article'), strip:rect('.runtime-checklist'),
|
||||
composer:rect('.composer-wrap'), workbar:rect('[data-workbar]'), padding:getComputedStyle(document.querySelector('.chat')).paddingTop };
|
||||
})()`)
|
||||
assert.ok(geometry.first.top >= geometry.strip.bottom, 'initial message clears both headers')
|
||||
assert.equal(geometry.strip.top, 58, 'context strip stays below topbar')
|
||||
assert.equal(geometry.workbar.top, 58, 'workbar stays in original row')
|
||||
assert.equal(geometry.viewport.top, enabled ? 0 : geometry.strip.bottom, 'actual viewport extension')
|
||||
assert.equal(geometry.padding, '0px', 'inset belongs to scrolling content')
|
||||
await evaluate(`(() => { const chat = document.querySelector('.chat-history-pane[data-active="true"] .chat');
|
||||
const message = chat.querySelector('article'); chat.scrollTop += message.getBoundingClientRect().top - 20; })()`)
|
||||
const overlap = await evaluate(`(() => { const chat = document.querySelector('.chat-history-pane[data-active="true"] .chat');
|
||||
const r = chat.querySelector('article').getBoundingClientRect(), v = chat.getBoundingClientRect(), t = document.querySelector('.topbar').getBoundingClientRect();
|
||||
return Math.min(r.bottom, v.bottom, t.bottom) - Math.max(r.top, v.top, t.top); })()`)
|
||||
assert.equal(overlap > 0, enabled, `${theme}/${enabled}: actual visible message/topbar intersection`)
|
||||
const clicks = await evaluate(`Number(document.querySelector('[data-clicks]').dataset.clicks)`)
|
||||
await click('.topbar button')
|
||||
await waitFor(`Number(document.querySelector('[data-clicks]').dataset.clicks) === ${clicks + 1}`)
|
||||
await click('.chat-scroll-to-bottom')
|
||||
await waitFor(`(() => { const e = document.querySelector('.chat-history-pane[data-active="true"] .chat'); return e.scrollHeight - e.clientHeight - e.scrollTop < 1; })()`)
|
||||
const bottom = await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article:last-child').getBoundingClientRect().bottom`)
|
||||
assert.ok(bottom <= geometry.composer.top, 'last message is above composer')
|
||||
assert.equal(await evaluate(`document.querySelector('[aria-label="Composer"]').value`), 'Draft')
|
||||
console.log(`FrostedGlass ${theme}/${enabled}: initial clearance, real message overlap=${overlap}, native click and jump-to-bottom passed`)
|
||||
}
|
||||
}
|
||||
await evaluate(`for (const e of [document.documentElement, document.querySelector('.app-shell')]) e.dataset.frostedGlass = 'true';
|
||||
document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 400;`)
|
||||
await waitFor("document.querySelector('.chat-scroll-to-bottom') !== null")
|
||||
await click('#switch-conversation')
|
||||
await waitFor(`document.querySelector('.chat-history-pane[data-active="true"]').dataset.conversationId === 'b'`)
|
||||
await click('#switch-conversation')
|
||||
await waitFor(`document.querySelector('.chat-history-pane[data-active="true"]').dataset.conversationId === 'a'`)
|
||||
assert.equal(await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop`), 400, 'keep-alive scroll position')
|
||||
await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 0`)
|
||||
const anchor = await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article').getBoundingClientRect().top`)
|
||||
await click('.load-earlier-messages')
|
||||
await waitFor(`document.querySelectorAll('.chat-history-pane[data-active="true"] article').length === 100`)
|
||||
const restoredAnchor = await evaluate(`document.querySelectorAll('.chat-history-pane[data-active="true"] article')[20].getBoundingClientRect().top`)
|
||||
assert.ok(Math.abs(restoredAnchor - anchor) < 1, 'prepend preserves message anchor')
|
||||
await click('.runtime-checklist__toggle')
|
||||
await waitFor(`!!document.querySelector('.runtime-checklist__content')`)
|
||||
assert.equal(await evaluate(`(() => { const e = document.querySelector('.runtime-checklist__content'), r = e.getBoundingClientRect();
|
||||
return e.closest('.floating-portal').matches(':popover-open') && e.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)); })()`), true, 'checklist top layer remains above the overlay')
|
||||
await click('.runtime-checklist__toggle')
|
||||
await waitFor(`!document.querySelector('.runtime-checklist__content')`)
|
||||
await click('#switch-context')
|
||||
await waitFor(`!document.querySelector('.runtime-checklist')`)
|
||||
await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 0`)
|
||||
await waitFor(`document.querySelector('.chat-history-pane[data-active="true"] article').getBoundingClientRect().top >= 58`)
|
||||
await click('#switch-route')
|
||||
await waitFor(`!document.querySelector('[data-route="activity"]').hidden`)
|
||||
assert.equal(await evaluate(`document.querySelector('.workspace').getBoundingClientRect().top`), 58, 'non-chat route keeps original layout')
|
||||
await click('#switch-route')
|
||||
await waitFor(`!document.querySelector('[data-route="chat"]').hidden`)
|
||||
assert.equal(await evaluate(`document.querySelector('.workspace').getBoundingClientRect().top`), 0, 'chat overlay restored on route return')
|
||||
if (process.platform === 'win32') {
|
||||
const koffi = createRequire(join(process.cwd(), 'package.json'))('koffi')
|
||||
const send = koffi.load('user32.dll').func('intptr_t __stdcall SendMessageW(void *hWnd, uint32_t Msg, uintptr_t wParam, intptr_t lParam)')
|
||||
const hwnd = koffi.decode(win.getNativeWindowHandle(), 'void *')
|
||||
const points = await evaluate(`(() => { const button = document.querySelector('.topbar button').getBoundingClientRect();
|
||||
const last = document.querySelector('#short-chat').getBoundingClientRect(), actions = document.querySelector('.topbar__actions').getBoundingClientRect();
|
||||
return { button: { x: button.x + button.width / 2, y: button.y + button.height / 2 },
|
||||
caption: { x: (last.right + actions.left) / 2, y: 25 } }; })()`)
|
||||
const hit = point => {
|
||||
const bounds = win.getContentBounds()
|
||||
const physical = screen.dipToScreenPoint({ x: Math.round(bounds.x + point.x), y: Math.round(bounds.y + point.y) })
|
||||
return Number(send(hwnd, 0x84, 0, (physical.y << 16) | (physical.x & 0xffff)))
|
||||
}
|
||||
for (let i = 0; i < 40 && hit(points.caption) !== 2; i++) await setTimeout(25)
|
||||
assert.equal(hit(points.caption), 2, 'native caption remains draggable')
|
||||
assert.equal(hit(points.button), 1, 'native button remains HTCLIENT')
|
||||
console.log('FrostedGlass Windows native hit test: HTCAPTION on titlebar, HTCLIENT on button')
|
||||
}
|
||||
await click('#short-chat')
|
||||
await waitFor(`document.querySelector('.chat-history-pane[data-active="true"]').dataset.conversationId === 'short'`)
|
||||
for (const theme of ['light', 'dark']) {
|
||||
for (const enabled of [true, false]) {
|
||||
win.setContentSize(960, 540)
|
||||
await evaluate(`document.documentElement.dataset.theme = '${theme}';
|
||||
for (const e of [document.documentElement, document.querySelector('.app-shell')]) e.dataset.frostedGlass = '${enabled}'`)
|
||||
await waitFor(`(() => { const chat = document.querySelector('.chat-history-pane[data-active="true"] .chat');
|
||||
const first = chat.querySelector('article').getBoundingClientRect(), footer = document.querySelector('.composer-wrap').getBoundingClientRect();
|
||||
return innerHeight === 540 && first.top >= 58 && first.bottom <= footer.top && footer.bottom <= innerHeight && chat.scrollTop === 0; })()`)
|
||||
}
|
||||
}
|
||||
console.log('FrostedGlass toggle anchor, keep-alive, prepend, top-layer hit, no-context, route isolation and short chat at 960x540 passed')
|
||||
app.exit(0)
|
||||
}).catch(error => {
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChatHistoryPane } from '../../src/renderer/src/ChatHistoryPane'
|
||||
import type { Conversation } from '../../src/renderer/src/chat-conversation'
|
||||
import { RuntimeChecklistStrip } from '../../src/renderer/src/RuntimeChecklistStrip'
|
||||
import { PageShell } from '../../src/renderer/src/WorkspacePrimitives'
|
||||
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
|
||||
import '../../src/renderer/src/styles.css'
|
||||
|
||||
const conversations: Conversation[] = ['a', 'b'].map(id => ({
|
||||
id, title: id, updatedAt: 1,
|
||||
messages: Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `${id}-${index}`, role: 'user', createdAt: index + 1, state: 'complete',
|
||||
content: `Message ${id}-${index}: Scroll this actual chat content behind the title bar.\n\nSecond paragraph for a visible message surface.`,
|
||||
})),
|
||||
}))
|
||||
conversations.push({ id: 'short', title: 'Short', updatedAt: 1, messages: [conversations[0].messages[0]] })
|
||||
const noop = (): void => {}
|
||||
const complete = async (): Promise<void> => {}
|
||||
const artifacts = new Map()
|
||||
|
||||
function Fixture(): React.JSX.Element {
|
||||
const [active, setActive] = useState('a')
|
||||
const [route, setRoute] = useState('chat')
|
||||
const [context, setContext] = useState(true)
|
||||
const [counts, setCounts] = useState<Record<string, number>>({ a: 80, b: 100, short: 100 })
|
||||
const [clicks, setClicks] = useState(0)
|
||||
return <><div className="app-shell"><div className="app-frame">
|
||||
<header className="topbar"><button data-clicks={clicks} onClick={() => setClicks(clicks + 1)}>Button</button>
|
||||
<input aria-label="Input" /><select aria-label="Select"><option>Option</option></select>
|
||||
<button id="switch-conversation" onClick={() => setActive(active === 'a' ? 'b' : 'a')}>Conversation</button>
|
||||
<button id="switch-route" onClick={() => setRoute(route === 'chat' ? 'activity' : 'chat')}>Route</button>
|
||||
<button id="switch-context" onClick={() => setContext(!context)}>Context</button>
|
||||
<button id="short-chat" onClick={() => setActive('short')}>Short</button>
|
||||
<div className="topbar__actions">Actions</div>
|
||||
<div className="window-controls"><button className="window-control">X</button></div>
|
||||
</header>
|
||||
<div className="app-content"><main className="workspace">
|
||||
<div className="workspace-route-cache" data-route="chat" hidden={route !== 'chat'}>
|
||||
<PageShell variant="reading"><div className="chat-scroll-region">
|
||||
{conversations.map(conversation => <ChatHistoryPane key={conversation.id}
|
||||
conversation={conversation} active={route === 'chat' && active === conversation.id}
|
||||
artifactById={artifacts} conversationHtmlRenderingEnabled={false} locale="en-US"
|
||||
onOpenImageModelSettings={noop} onReselectImageSources={noop} onEditImage={noop}
|
||||
onCopyMessage={async () => true} onDownloadImage={noop} onOpenCitationContext={complete}
|
||||
onOpenCitationSource={complete} onOpenImage={noop} onRespondApproval={complete}
|
||||
onRespondQuestion={complete} onRetry={noop} onScrollSnapshotChange={noop}
|
||||
onSetInput={noop} quickActions={[]} visibleMessageCount={counts[conversation.id]}
|
||||
onVisibleMessageCountChange={(id, count) => setCounts(current => ({ ...current, [id]: count }))}
|
||||
taskStrip={context ? <div className="conversation-context-strips"><RuntimeChecklistStrip messages={[{
|
||||
id: 'glass', role: 'assistant', content: '', createdAt: 1, state: 'streaming',
|
||||
runtimeChecklist: { source: 'opencode', items: [
|
||||
{ content: 'Inspect', status: 'completed' },
|
||||
{ content: 'Implement', status: 'in_progress' },
|
||||
{ content: 'Verify', status: 'pending' },
|
||||
] },
|
||||
}]} /></div> : undefined}
|
||||
/>)}
|
||||
</div><footer className="composer-wrap"><textarea aria-label="Composer" defaultValue="Draft" /></footer></PageShell>
|
||||
</div>
|
||||
<div className="workspace-route-cache" data-route="activity" hidden={route === 'chat'}><PageShell variant="dashboard">Activity</PageShell></div>
|
||||
</main><aside data-workbar style={{ width: 300, flexShrink: 0 }}>Workbar</aside></div>
|
||||
</div></div><div className="anchored-menu" data-ordinary-menu="true">Ordinary menu</div></>
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<UiLocaleProvider><Fixture /></UiLocaleProvider>)
|
||||
Reference in New Issue
Block a user