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:
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user