Files
goodbuddy/src/main/ipc.test.ts
T
mesalogo a4b64a1641 feat: preview Agent HTML in conversations and unblock long replies
Agent replies that produced HTML could only be read as source, so users had
to copy the markup into an external file to see the result. Completed Agent
and Subagent output now renders complete HTML documents, single-root HTML
replies, and `html` / `htm` code blocks as an isolated static preview in
place. Each preview keeps a persistent notice that scripts and network are
disabled and offers the same icon actions as Mermaid diagrams: a source
toggle and a full-screen preview. The new "render HTML in conversations"
switch under Platform Features / General is on by default, applies to the
open conversation immediately, and falls back to HTML source when disabled.
Previews cannot run scripts, reach the network, submit forms, open windows,
or load remote media; author stylesheets and inline styles are preserved so
the intended layout survives, and remote resource loading is refused by the
preview CSP instead.

Direct-model replies no longer fail once they grow large. The byte caps on
streaming responses, single stream blocks, non-streaming replies, model tool
arguments, tool request context, aggregate tool results, and accumulated
answer text are removed, so users stop seeing "模型接口流式响应超过安全限制"
on long answers. Response length is now governed by the request timeout,
cancellation, and context compression. Image generation responses keep their
decodable-image validation.

A message sent after a failed or interrupted run stayed in the pending queue
instead of running. The conversation is now released whenever a run reaches
any terminal state, not only when the renderer reports readiness again, and a
dispatched queue item whose run never starts releases its reservation and
returns to pending. Later messages dispatch without extra user action.

Release note: Agent 回复中的 HTML 现在可直接在会话内预览,支持查看源码和全屏预览,默认开启并可在“平台功能 / 通用设置”关闭;直连模型的长回复不再因大小限制中断;上一次运行失败或被中断后,新发送的消息会立即执行而不再排队等待。
2026-09-05 09:10:44 +08:00

10367 lines
300 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import {
mkdtemp,
readFile,
realpath,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ipcChannels } from '../shared/ipc-channels'
import type {
AssistantProject,
ConversationQueueItem
} from '../shared/assistant-contracts'
import type { AgentEvent, BrowserLiveState } from '../shared/contracts'
import type {
SshHostAgentConnectionStatus
} from '../shared/ssh-host-contracts'
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
import { AssistantDatabase } from './assistant/assistant-database'
import { BrowserNavigationStoppedError } from './browser/browser-service'
import {
registerIpcHandlers,
sendRemoteEnvironmentUpdateProgress,
sendRemoteProjectSaveProgress
} from './ipc'
describe('embedding IPC boundary', () => {
it('validates explicit IDs and keeps ZIP paths in the native dialog', async () => {
const source = await readFile(
join(process.cwd(), 'src', 'main', 'ipc.ts'),
'utf8'
)
const region =
source.match(
/const requireEmbeddingProvider[\s\S]*?registerHandler\(ipcChannels\.speechModelsGet/u
)?.[0] ?? ''
expect(region).toContain('embeddingConnectionIdRequestSchema.parse(input)')
expect(region).toContain('embeddingModelInstallInputSchema.parse(input)')
expect(region).toContain('embeddingModelActionInputSchema.parse(input)')
expect(region).toContain(
'embeddingModelProgressSnapshotSchema.parse'
)
expect(region).toContain(
'embeddingModelManager.getProgressSnapshot()'
)
expect(region).toContain("properties: ['openFile']")
expect(region).toContain('filters: modelArchiveDialogFilters')
expect(region).not.toMatch(
/embeddingModelsImportArchive[\s\S]{0,300}input\.(?:path|archivePath|directory)/u
)
expect(region).not.toMatch(
/(?:agent|selectedRuntimes|remoteModelGateway).{0,80}(?:embedding|vector)/iu
)
})
})
describe('local tool environment IPC boundary', () => {
it('trust-checks every operation and accepts only shared bounded schemas', async () => {
const source = await readFile(
join(process.cwd(), 'src', 'main', 'ipc.ts'),
'utf8'
)
const region =
source.match(
/const requireLocalToolEnvironmentService[\s\S]*?registerHandler\(ipcChannels\.shortcutSettingsGet/u
)?.[0] ?? ''
expect(region.match(/assertTrustedSender\(event, window\)/gu)).toHaveLength(8)
expect(region).toContain('localToolEnvironmentSettingsSchema.parse(input)')
expect(region).toContain('localToolKindInputSchema.parse(input)')
expect(region).toContain('localToolDiagnoseInputSchema.parse(input)')
expect(region.match(/z\.undefined\(\)\.parse\(input\)/gu)).toHaveLength(5)
expect(region).not.toMatch(
/\b(?:command|args|env|url|hash|installDirectory|shimContents)\b/u
)
})
})
describe('terminal IPC boundary', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('validates terminal requests and routes them through the trusted renderer owner', async () => {
const webContents = {
id: 73,
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const snapshot = {
sessionId: '00000000-0000-4000-8000-000000000073',
target: { type: 'local' as const },
title: 'PowerShell',
state: 'running' as const,
cwd: 'C:\\Workspace',
cols: 120,
rows: 36,
lastSequence: 0
}
const terminalSessionManager = {
create: vi.fn(async () => snapshot),
enableEventDelivery: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
snapshot: vi.fn(() => snapshot),
acknowledge: vi.fn(),
close: vi.fn(async () => ({
...snapshot,
state: 'closed' as const
})),
closeOwner: vi.fn(async () => undefined)
}
const args: Parameters<typeof registerIpcHandlers> = [
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {}),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
terminalSessionManager as never
]
const dispose = registerIpcHandlers(...args)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
await expect(
electronMocks.handlers.get(ipcChannels.terminalCreate)?.(event, {
target: { type: 'local' },
cols: 120,
rows: 36
})
).resolves.toEqual(snapshot)
expect(terminalSessionManager.create).toHaveBeenCalledWith(73, {
target: { type: 'local' },
cols: 120,
rows: 36
})
await vi.waitFor(() =>
expect(
terminalSessionManager.enableEventDelivery
).toHaveBeenCalledWith(73, snapshot.sessionId)
)
electronMocks.handlers.get(ipcChannels.terminalWrite)?.(event, {
sessionId: snapshot.sessionId,
data: 'echo ready\r'
})
electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, {
sessionId: snapshot.sessionId,
cols: 100,
rows: 28
})
electronMocks.handlers.get(ipcChannels.terminalAck)?.(event, {
sessionId: snapshot.sessionId,
sequence: 4
})
expect(
electronMocks.handlers.get(ipcChannels.terminalSnapshot)?.(
event,
{ sessionId: snapshot.sessionId }
)
).toEqual(snapshot)
await expect(
electronMocks.handlers.get(ipcChannels.terminalClose)?.(event, {
sessionId: snapshot.sessionId
})
).resolves.toMatchObject({ state: 'closed' })
expect(terminalSessionManager.write).toHaveBeenCalledWith(
73,
snapshot.sessionId,
'echo ready\r'
)
expect(terminalSessionManager.resize).toHaveBeenCalledWith(
73,
snapshot.sessionId,
{ cols: 100, rows: 28 }
)
expect(terminalSessionManager.acknowledge).toHaveBeenCalledWith(
73,
snapshot.sessionId,
4
)
expect(terminalSessionManager.snapshot).toHaveBeenCalledWith(
73,
snapshot.sessionId
)
expect(terminalSessionManager.close).toHaveBeenCalledWith(
73,
snapshot.sessionId
)
expect(
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.(event)
).toBe('Get-Location\r')
expect(electronMocks.readClipboardText).toHaveBeenCalledOnce()
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
event,
'## Copied response'
)
expect(electronMocks.writeClipboardText).toHaveBeenCalledWith(
'## Copied response'
)
expect(() =>
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.({
sender: { ...webContents, id: 99 },
senderFrame: webContents.mainFrame
})
).toThrow('拒绝来自未知窗口的 IPC 请求')
expect(() =>
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
event,
{ text: 'invalid' }
)
).toThrow()
expect(() =>
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
{
sender: { ...webContents, id: 99 },
senderFrame: webContents.mainFrame
},
'untrusted'
)
).toThrow('拒绝来自未知窗口的 IPC 请求')
expect(() =>
electronMocks.handlers.get(ipcChannels.terminalWrite)?.(
{
sender: { ...webContents, id: 99 },
senderFrame: webContents.mainFrame
},
{
sessionId: snapshot.sessionId,
data: 'untrusted'
}
)
).toThrow('拒绝来自未知窗口的 IPC 请求')
expect(() =>
electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, {
sessionId: snapshot.sessionId,
cols: 0,
rows: 28
})
).toThrow()
await dispose()
expect(terminalSessionManager.closeOwner).toHaveBeenCalledWith(73)
})
})
type InvokeHandler = (event: unknown, input?: unknown) => unknown
type KnowledgeGrantMock = (
requestId: string,
libraryIds: readonly string[],
signal: AbortSignal,
access: 'read' | 'write'
) => string
const electronMocks = vi.hoisted(() => {
const handlers = new Map<string, InvokeHandler>()
return {
handlers,
handle: vi.fn((channel: string, handler: InvokeHandler) => {
handlers.set(channel, handler)
}),
removeHandler: vi.fn((channel: string) => {
handlers.delete(channel)
}),
showOpenDialog: vi.fn(async () => ({
canceled: true,
filePaths: [] as string[]
})),
showSaveDialog: vi.fn(async () => ({
canceled: true,
filePath: undefined as string | undefined
})),
openPath: vi.fn(async () => ''),
showItemInFolder: vi.fn(),
openExternal: vi.fn(async () => undefined),
readClipboardText: vi.fn(() => 'Get-Location\r'),
writeClipboardText: vi.fn()
}
})
const channelMocks = vi.hoisted(() => ({
executor: undefined as
| ((
message: {
channel: string
eventId: string
senderId: string
conversationId: string
conversationType: 'direct' | 'group'
text: string
mentioned: boolean
workMode: 'ask'
attachments?: Array<{
name: string
mimeType: string
size: number
kind: 'image' | 'file'
dataBase64: string
}>
attachmentError?: string
},
signal: AbortSignal,
reportProgress?: (result: {
status: string
output?: string
error?: string
}) => Promise<void>
) => Promise<{
status: string
output?: string
error?: string
attachments?: Array<{
name: string
mimeType: string
size: number
kind: 'image' | 'file'
dataBase64: string
}>
}>)
| undefined,
stop: vi.fn(async () => undefined)
}))
const runtimeFactoryMocks = vi.hoisted(() => ({
createModelProfileRuntime: vi.fn(),
createDefaultModelRuntime: vi.fn()
}))
describe('registerIpcHandlers computer capabilities', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
channelMocks.stop.mockResolvedValue(undefined)
})
it('validates computer capability requests and restricts them to the trusted renderer', async () => {
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const snapshot = {
skills: [],
mcpServers: [],
computerCapabilities: [],
browserProfiles: { profiles: [], defaultProfileId: null }
}
const capabilityService = {
importSkill: vi.fn(async () => snapshot),
setBuiltinMcpServerEnabled: vi.fn(async () => snapshot),
setBuiltinMcpServerAssignments: vi.fn(async () => snapshot),
setComputerCapabilityEnabled: vi.fn(async () => snapshot),
getComputerCapabilityStatus: vi.fn(async () => ({
enabled: true,
supported: true
})),
setWebSearchEnabled: vi.fn(async () => snapshot),
createBrowserProfile: vi.fn(async () => snapshot),
diagnoseComputerCapability: vi.fn(async () => ({
capabilityId: 'host-browser-control',
status: 'disabled',
checkedAt: '2026-08-05T00:00:00.000Z',
checks: []
}))
}
const onRuntimeSettingsChanged = vi.fn(async () => {})
const interact = vi.fn(async () => {})
const navigate = vi.fn(async () => ({
url: 'https://example.com/',
origin: 'https://example.com'
}))
const back = vi.fn(async () => ({
url: 'https://example.com/previous',
origin: 'https://example.com'
}))
const reload = vi.fn(async () => ({
url: 'https://example.com/',
origin: 'https://example.com'
}))
const stopLoading = vi.fn(async () => true)
const releaseConversation = vi.fn(async () => {})
const selectFiles = vi.fn(
async (
_window: unknown,
onProgress: (progress: {
phase: 'parsing'
fileName: string
fileNumber: number
fileCount: number
}) => void
) => {
onProgress({
phase: 'parsing',
fileName: 'scan.pdf',
fileNumber: 1,
fileCount: 1
})
return []
}
)
let browserStateListener:
| ((state: BrowserLiveState) => void)
| undefined
const shortcutSnapshot = {
settings: {
enabled: true,
accelerator: 'Control+Alt+K'
},
defaultSettings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
displayAccelerator: 'Ctrl + Alt + K',
registered: true,
registeredAccelerator: 'Control+Alt+K',
status: 'registered' as const
}
const shortcutSettingsService = {
getSnapshot: vi.fn(() => shortcutSnapshot),
update: vi.fn(async () => ({
ok: true as const,
snapshot: shortcutSnapshot
}))
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
capabilityService as never,
{ clear: vi.fn(), selectFiles } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
onRuntimeSettingsChanged,
undefined,
{
navigate,
back,
reload,
stopLoading,
interact,
releaseConversation,
onState: (listener) => {
browserStateListener = listener
return vi.fn()
}
},
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
shortcutSettingsService as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
expect(
electronMocks.handlers.get(ipcChannels.appInfo)?.(event)
).toMatchObject({
shortcut: 'Ctrl + Alt + K',
shortcutStatus: 'registered'
})
expect(
electronMocks.handlers.get(ipcChannels.shortcutSettingsGet)?.(
event
)
).toEqual(shortcutSnapshot)
await expect(
electronMocks.handlers.get(ipcChannels.shortcutSettingsUpdate)?.(
event,
{ enabled: true, accelerator: 'ctrl+alt+k' }
)
).resolves.toEqual({
ok: true,
snapshot: shortcutSnapshot
})
expect(shortcutSettingsService.update).toHaveBeenCalledWith({
enabled: true,
accelerator: 'Control+Alt+K'
})
await expect(
electronMocks.handlers.get(ipcChannels.contextSelectFiles)?.(event)
).resolves.toEqual([])
expect(selectFiles).toHaveBeenCalledWith(window, expect.any(Function))
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.contextFileSelectionProgress,
{
phase: 'parsing',
fileName: 'scan.pdf',
fileNumber: 1,
fileCount: 1
}
)
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesToggleComputer
)?.(event, {
capabilityId: 'host-browser-control',
enabled: true
})
).resolves.toEqual(snapshot)
expect(
capabilityService.setComputerCapabilityEnabled
).toHaveBeenCalledWith('host-browser-control', true)
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesToggleWebSearch
)?.(event, false)
).resolves.toEqual(snapshot)
expect(capabilityService.setWebSearchEnabled).toHaveBeenCalledWith(false)
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(2)
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesToggleBuiltinMcp
)?.(event, {
serverId: 'knowledge-base',
enabled: false
})
).resolves.toEqual(snapshot)
expect(
capabilityService.setBuiltinMcpServerEnabled
).toHaveBeenCalledWith('knowledge-base', false)
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesAssignBuiltinMcp
)?.(event, {
serverId: 'knowledge-base',
assignments: ['model', 'continue']
})
).resolves.toEqual(snapshot)
expect(
capabilityService.setBuiltinMcpServerAssignments
).toHaveBeenCalledWith('knowledge-base', ['model', 'continue'])
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(4)
electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: ['C:\\meeting-helper.zip']
})
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesImportSkill
)?.(event, 'zip')
).resolves.toEqual(snapshot)
expect(electronMocks.showOpenDialog).toHaveBeenCalledWith(
window,
expect.objectContaining({
properties: ['openFile'],
filters: [{ name: 'Skill ZIP', extensions: ['zip'] }]
})
)
expect(capabilityService.importSkill).toHaveBeenCalledWith(
'C:\\meeting-helper.zip'
)
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesImportSkill
)?.(event, 'unsupported')
).rejects.toThrow()
browserStateListener?.({
conversationId: 'browser-conversation',
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
frameDataUrl: 'data:image/jpeg;base64,frame',
updatedAt: 1
})
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.browserState,
expect.objectContaining({
conversationId: 'browser-conversation',
status: 'ready'
})
)
browserStateListener?.({
conversationId: 'browser-conversation',
status: 'acting',
sessionActive: true,
isLoading: false,
canGoBack: false,
frameDataUrl: 'data:image/jpeg;base64,frame',
updatedAt: 2
})
const repeatedFramePayload = webContents.send.mock.calls.at(-1)?.[1]
expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl')
browserStateListener?.({
conversationId: 'browser-conversation',
status: 'stopped',
sessionActive: false,
isLoading: false,
canGoBack: false,
updatedAt: 3
})
browserStateListener?.({
conversationId: 'browser-conversation',
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
frameDataUrl: 'data:image/jpeg;base64,frame',
updatedAt: 4
})
expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty(
'frameDataUrl',
'data:image/jpeg;base64,frame'
)
await expect(
electronMocks.handlers.get(ipcChannels.browserStop)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toBeUndefined()
expect(releaseConversation).toHaveBeenCalledWith(
'browser-conversation'
)
await expect(
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
conversationId: 'browser-conversation',
url: 'https://example.com/'
})
).resolves.toBeUndefined()
expect(navigate).toHaveBeenCalledWith(
'browser-conversation',
'https://example.com/',
expect.any(AbortSignal)
)
await expect(
electronMocks.handlers.get(ipcChannels.browserBack)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toBeUndefined()
expect(back).toHaveBeenCalledWith(
'browser-conversation',
expect.any(AbortSignal)
)
await expect(
electronMocks.handlers.get(ipcChannels.browserReload)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toBeUndefined()
expect(reload).toHaveBeenCalledWith(
'browser-conversation',
expect.any(AbortSignal)
)
await expect(
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
event,
{ conversationId: 'browser-conversation' }
)
).resolves.toBeUndefined()
expect(stopLoading).toHaveBeenCalledWith('browser-conversation')
await expect(
electronMocks.handlers.get(ipcChannels.browserInteract)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toBeUndefined()
expect(interact).toHaveBeenCalledWith(
'browser-conversation',
expect.any(AbortSignal)
)
navigate.mockRejectedValueOnce(new BrowserNavigationStoppedError())
await expect(
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
conversationId: 'browser-conversation',
url: 'https://example.com/slow'
})
).resolves.toBeUndefined()
capabilityService.getComputerCapabilityStatus.mockResolvedValueOnce({
enabled: false,
supported: true
})
await expect(
electronMocks.handlers.get(ipcChannels.browserBack)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toBeUndefined()
expect(back).toHaveBeenCalledTimes(2)
capabilityService.getComputerCapabilityStatus.mockResolvedValueOnce({
enabled: true,
supported: false
})
await expect(
electronMocks.handlers.get(ipcChannels.browserReload)?.(event, {
conversationId: 'browser-conversation'
})
).rejects.toThrow('不支持')
await expect(
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
conversationId: 'browser-conversation',
url: 'https://example.com/',
debugger: true
})
).rejects.toThrow()
await expect(
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
{
sender: {},
senderFrame: webContents.mainFrame
},
{ conversationId: 'browser-conversation' }
)
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
event,
{
conversationId: 'browser-conversation',
rawElectron: true
}
)
).rejects.toThrow()
expect(() =>
electronMocks.handlers.get(
ipcChannels.capabilitiesCreateBrowserProfile
)?.(event, {
name: '工作配置',
executable: 'C:\\unsafe.exe'
})
).toThrow()
expect(capabilityService.createBrowserProfile).not.toHaveBeenCalled()
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesCreateBrowserProfile
)?.(event, {
name: '工作配置'
})
).resolves.toEqual(snapshot)
expect(capabilityService.createBrowserProfile).toHaveBeenCalledWith(
'工作配置'
)
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(5)
expect(() =>
electronMocks.handlers.get(
ipcChannels.capabilitiesDiagnoseComputer
)?.(
{
sender: {},
senderFrame: webContents.mainFrame
},
'host-browser-control'
)
).toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
})
})
describe('registerIpcHandlers update source routing', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('uses the persisted source for checks and the download page', async () => {
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const result = {
updateAvailable: true,
currentVersion: '1.0.0',
latestVersion: '1.1.0',
releaseUrl: 'https://mesalogo.github.io/goodbuddy/#download',
target: {
platform: 'windows',
arch: 'x64',
formats: ['nsis', 'portable'],
files: []
}
}
const getApplicationSettings = vi
.fn()
.mockResolvedValue({ updateSource: 'mirror' })
const versionChecker = {
check: vi.fn(async () => result)
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined),
undefined,
undefined,
undefined,
undefined,
{ get: getApplicationSettings } as never,
versionChecker as never
)
await expect(
electronMocks.handlers.get(ipcChannels.versionCheck)?.(event)
).resolves.toEqual(result)
expect(versionChecker.check).toHaveBeenCalledWith('mirror')
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.versionCheckResult,
result
)
await expect(
electronMocks.handlers.get(
ipcChannels.versionOpenReleasePage
)?.(event)
).resolves.toBeUndefined()
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
'https://mesalogo.github.io/goodbuddy/#download'
)
getApplicationSettings.mockResolvedValueOnce({
updateSource: 'github'
})
await expect(
electronMocks.handlers.get(
ipcChannels.versionOpenReleasePage
)?.(event)
).resolves.toBeUndefined()
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
'https://github.com/mesalogo/goodbuddy/releases'
)
await dispose()
})
})
describe('registerIpcHandlers model download source routing', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('uses the persisted source and rejects stale renderer requests', async () => {
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const speechSnapshot = {
rootDirectory: 'C:\\models\\speech',
selectedDownloadSource: 'hugging-face',
catalog: [],
installed: [],
operations: [],
selectedModelId: null
}
const ocrSnapshot = {
settings: {},
status: {},
ocrModels: {
rootDirectory: 'C:\\models\\ocr',
selectedDownloadSource: 'hugging-face',
catalog: [],
installed: [],
operations: []
}
}
const speechModelManager = {
install: vi.fn(async () => undefined),
getSnapshot: vi.fn(async () => speechSnapshot),
getRepositoryUrl: vi.fn(
() => 'https://huggingface.co/example/speech'
)
}
const documentOcrModelManager = {
install: vi.fn(async () => undefined),
getProgressSnapshot: vi.fn(() => ({
operations: []
})),
getRepositoryUrl: vi.fn(
() => 'https://huggingface.co/example/ocr'
)
}
const documentParsingService = {
snapshot: vi.fn(async () => ocrSnapshot)
}
const applicationSettingsStore = {
get: vi.fn(async () => ({
modelDownloadSource: 'hugging-face'
}))
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined),
undefined,
undefined,
undefined,
undefined,
applicationSettingsStore as never,
undefined,
speechModelManager as never,
undefined,
undefined,
undefined,
undefined,
undefined,
documentParsingService as never,
documentOcrModelManager as never
)
await expect(
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
event,
{
modelId: 'speech-model',
expectedDownloadSource: 'hugging-face'
}
)
).resolves.toEqual(speechSnapshot)
expect(speechModelManager.install).toHaveBeenCalledWith(
'speech-model',
'hugging-face'
)
await expect(
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
event,
{
modelId: 'speech-model',
expectedDownloadSource: 'modelscope'
}
)
).rejects.toThrow('模型下载源已变化')
expect(speechModelManager.install).toHaveBeenCalledTimes(1)
applicationSettingsStore.get.mockRejectedValueOnce(
new Error('settings unavailable')
)
await expect(
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
event,
{
modelId: 'speech-model',
expectedDownloadSource: 'hugging-face'
}
)
).rejects.toThrow('settings unavailable')
expect(speechModelManager.install).toHaveBeenCalledTimes(1)
await expect(
electronMocks.handlers.get(
ipcChannels.documentOcrModelsInstall
)?.(event, {
modelId: 'ocr-model',
expectedDownloadSource: 'hugging-face'
})
).resolves.toEqual(ocrSnapshot)
expect(documentOcrModelManager.install).toHaveBeenCalledWith(
'ocr-model',
'hugging-face'
)
expect(
electronMocks.handlers.get(
ipcChannels.documentOcrModelsProgress
)?.(event)
).toEqual({ operations: [] })
expect(
documentOcrModelManager.getProgressSnapshot
).toHaveBeenCalledOnce()
expect(() =>
electronMocks.handlers
.get(ipcChannels.documentOcrModelsProgress)
?.({
sender: {},
senderFrame: webContents.mainFrame
})
).toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(
electronMocks.handlers.get(
ipcChannels.speechModelsOpenRepository
)?.(event, { modelId: 'speech-model' })
).resolves.toBeUndefined()
expect(speechModelManager.getRepositoryUrl).toHaveBeenCalledWith(
'speech-model',
'hugging-face'
)
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
'https://huggingface.co/example/speech'
)
await expect(
electronMocks.handlers.get(
ipcChannels.documentOcrModelsOpenRepository
)?.(event, { modelId: 'ocr-model' })
).resolves.toBeUndefined()
expect(
documentOcrModelManager.getRepositoryUrl
).toHaveBeenCalledWith('ocr-model', 'hugging-face')
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
'https://huggingface.co/example/ocr'
)
await dispose()
})
})
vi.mock('electron', () => ({
app: {
getName: vi.fn(() => 'GoodBuddy'),
getVersion: vi.fn(() => '0.1.0')
},
BrowserWindow: class {},
clipboard: {
readText: electronMocks.readClipboardText,
writeText: electronMocks.writeClipboardText
},
dialog: {
showOpenDialog: electronMocks.showOpenDialog,
showSaveDialog: electronMocks.showSaveDialog
},
ipcMain: {
handle: electronMocks.handle,
removeHandler: electronMocks.removeHandler
},
Notification: class {
static isSupported(): boolean {
return false
}
},
shell: {
openPath: electronMocks.openPath,
showItemInFolder: electronMocks.showItemInFolder,
openExternal: electronMocks.openExternal
}
}))
vi.mock('./assistant/heartbeat-service', () => ({
HeartbeatService: class {
async processDue(): Promise<void> {}
}
}))
vi.mock('./agent/create-runtime', () => runtimeFactoryMocks)
vi.mock('./channels/channel-env', () => ({
isReadOnlyChannelMessage: (message: { workMode: string }) =>
message.workMode === 'ask',
startEnvironmentChannels: vi.fn(
(options: { executor: typeof channelMocks.executor }) => {
channelMocks.executor = options.executor
return [
{
start: vi.fn(async () => undefined),
stop: channelMocks.stop
}
]
}
)
}))
describe('registerIpcHandlers DSH runtime extensions', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('validates extension actions, reloads the Runtime, and trusts only the renderer', async () => {
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const snapshot = {
marketplaceEnabled: true,
catalog: [],
installed: []
}
const runtimeExtensionStore = {
getSnapshot: vi.fn(async () => snapshot),
applyWithResult: vi.fn(async () => ({
snapshot,
changed: true
}))
}
const onRuntimeSettingsChanged = vi.fn(async () => undefined)
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
onRuntimeSettingsChanged,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
runtimeExtensionStore as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const action = {
type: 'set-enabled',
extensionId: 'dsh-plugin-greet',
enabled: true
}
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeExtensionsSnapshot
)?.(event)
).resolves.toEqual(snapshot)
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeExtensionsApply
)?.(event, action)
).resolves.toEqual(snapshot)
expect(
runtimeExtensionStore.applyWithResult
).toHaveBeenCalledWith(action)
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
const marketplaceAction = {
type: 'set-marketplace-enabled',
enabled: false
}
runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({
snapshot: {
...snapshot,
marketplaceEnabled: false
},
changed: true
})
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeExtensionsApply
)?.(event, marketplaceAction)
).resolves.toEqual({
...snapshot,
marketplaceEnabled: false
})
expect(
runtimeExtensionStore.applyWithResult
).toHaveBeenLastCalledWith(marketplaceAction)
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({
snapshot,
changed: false
})
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeExtensionsApply
)?.(event, action)
).resolves.toEqual(snapshot)
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeExtensionsApply
)?.(event, {
...action,
extensionId: 'Invalid Extension'
})
).rejects.toThrow()
expect(() =>
electronMocks.handlers.get(
ipcChannels.runtimeExtensionsSnapshot
)?.({
sender: {},
senderFrame: webContents.mainFrame
})
).toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
})
})
describe('registerIpcHandlers SSH hosts', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('validates narrow host requests and restricts them to the trusted renderer', async () => {
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const hostId = '00000000-0000-4000-8000-000000000105'
const candidateId = '00000000-0000-4000-8000-000000000106'
const fingerprint = `SHA256:${'A'.repeat(43)}`
const host = {
id: hostId,
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent' as const,
credentialConfigured: true,
credentialSource: 'system-agent' as const,
hostKey: { state: 'unverified' as const, generation: 0 },
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
const snapshot = {
hosts: [host],
secureStorageAvailable: true
}
const sshHostService = {
getSnapshot: vi.fn(async () => snapshot),
remove: vi.fn(
async (
_hostId: string,
commitLocalDeletion?: () => unknown
) => commitLocalDeletion?.()
),
inspectDraftHostKey: vi.fn(async () => ({
candidateId,
hostId,
state: 'unverified' as const,
algorithm: 'ssh-ed25519',
fingerprintSha256: fingerprint
})),
discardCandidate: vi.fn(),
validateAndSave: vi.fn(async () => ({
host,
connection: {
hostId,
connected: true as const,
latencyMs: 12,
platform: 'linux' as const,
architecture: 'x64' as const,
shell: '/bin/bash',
homeDirectory: '/home/builder',
detail: 'SSH 已连接,远端系统为 linux/x64'
}
}))
}
const deleteProjectsReferencingSshHost = vi.fn(
(): Array<{ id: string; name: string }> => []
)
const listSshHostProjectReferences = vi.fn(() => [])
const remoteProject = {
id: '00000000-0000-4000-8000-000000000107',
name: 'Remote project'
}
const remoteProjectSaveService = {
save: vi.fn(async () => remoteProject),
cancelCurrent: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const directoryListing = {
path: '/srv',
homeDirectory: '/home/builder',
parentPath: '/',
entries: [
{
name: 'project',
path: '/srv/project'
}
],
truncated: false
}
const listDirectories = vi.fn<
(
hostId: string,
path?: string,
signal?: AbortSignal
) => Promise<typeof directoryListing>
>(async () => directoryListing)
const sshHostDirectoryBrowser = { listDirectories }
const remoteEnvironment = {
hostId,
checkedAt: '2030-01-01T00:00:00.000Z',
architecture: 'x64',
agent: {
state: 'current',
expected: {
version: '0.11.1',
architecture: 'x64'
},
installed: {
version: '0.11.1',
architecture: 'x64'
}
},
runtimes: [{
runtimeId: 'opencode',
provider: 'opencode',
state: 'current',
expected: {
version: '1.18.9',
architecture: 'x64'
},
installed: {
version: '1.18.9',
architecture: 'x64'
}
}],
remoteDownload: {
available: true,
source: 'mirror',
packageSize: 1024
}
} as const
const inspectRemoteEnvironment = vi.fn(
async () => remoteEnvironment
)
const sshHostRemoteEnvironmentInspector = {
inspect: inspectRemoteEnvironment
}
const remoteEnvironmentUpdateService = {
update: vi.fn(
async (
owner: typeof webContents,
request: {
hostId: string
method: 'remote-download' | 'goodbuddy-transfer'
},
onProgress?: (progress: {
hostId: string
method: 'remote-download' | 'goodbuddy-transfer'
phase: 'verifying'
}) => void
) => {
expect(owner).toBe(webContents)
onProgress?.({
hostId: request.hostId,
method: request.method,
phase: 'verifying'
})
}
),
cancel: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const agentPackageInventory = {
checkedAt: '2030-01-01T00:00:00.000Z',
catalog: {
state: 'available',
checkedAt: '2030-01-01T00:00:00.000Z',
error: null
},
entries: [
{
platform: 'linux',
architecture: 'x64',
state: 'verified',
version: '0.11.2',
latestVersion: '0.11.4',
updateAvailable: true,
remoteRuntimeVersion: '1.18.9',
agentProtocol: { major: 2, minor: 0 }
},
{
platform: 'linux',
architecture: 'arm64',
state: 'not-downloaded',
version: null,
latestVersion: '0.11.4',
updateAvailable: false,
remoteRuntimeVersion: null,
agentProtocol: null
}
]
} as const
const agentPackageManager = {
getSnapshot: vi.fn(async () => agentPackageInventory),
download: vi.fn(
async (
architecture: 'x64' | 'arm64',
onProgress?: (progress: {
architecture: 'x64' | 'arm64'
phase: 'downloading'
completedBytes: number
totalBytes: number
}) => void
) => {
onProgress?.({
architecture,
phase: 'downloading',
completedBytes: 1,
totalBytes: 2
})
return agentPackageInventory
}
),
importArchive: vi.fn(async () => agentPackageInventory),
getExportArchiveName: vi.fn(
async () =>
'goodbuddy-agent-0.11.2-linux-arm64.gbagent'
),
exportArchive: vi.fn(async () => undefined)
}
const selectedRuntimes = {
reset: vi.fn(async () => undefined)
}
let publishAgentConnectionStatus:
| ((status: SshHostAgentConnectionStatus) => void)
| undefined
const removeAgentConnectionStatusListener = vi.fn()
const remoteAgentConnectionManager = {
getHostConnectionState: vi.fn(() => 'ready' as const),
onHostConnectionStateChange: vi.fn(
(
listener: (status: SshHostAgentConnectionStatus) => void
) => {
publishAgentConnectionStatus = listener
return removeAgentConnectionStatusListener
}
)
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
listSshHostProjectReferences,
deleteProjectsReferencingSshHost
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined),
undefined, // onBeforeClearLocalData
undefined, // browserControl
undefined, // subagentService
undefined, // channelSettingsStore
{
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
} as never, // applicationSettingsStore
undefined, // versionChecker
undefined, // speechModelManager
undefined, // embeddingIndexCoordinator
selectedRuntimes as never,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
sshHostService as never,
undefined,
remoteProjectSaveService as never,
sshHostDirectoryBrowser as never,
sshHostRemoteEnvironmentInspector as never,
undefined,
undefined,
undefined,
remoteEnvironmentUpdateService as never,
agentPackageManager as never,
remoteAgentConnectionManager as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const createInput = {
name: ' Build host ',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
}
await expect(
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event)
).resolves.toEqual({
...snapshot,
projectReferences: {},
agentConnectionStatusByHostId: {
[hostId]: 'ready'
}
})
expect(
remoteAgentConnectionManager.getHostConnectionState
).toHaveBeenCalledWith(hostId)
publishAgentConnectionStatus?.({
hostId,
state: 'connecting'
})
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.sshHostsAgentConnectionStatus,
{
hostId,
state: 'connecting'
}
)
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsAgentPackageInventory
)?.(event, { refresh: true })
).resolves.toEqual(agentPackageInventory)
expect(
agentPackageManager.getSnapshot
).toHaveBeenCalledWith({ refresh: true })
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsAgentPackageDownload
)?.(event, { architecture: 'x64' })
).resolves.toEqual(agentPackageInventory)
expect(agentPackageManager.download).toHaveBeenCalledWith(
'x64',
expect.any(Function)
)
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.sshHostsAgentPackageProgress,
{
architecture: 'x64',
phase: 'downloading',
completedBytes: 1,
totalBytes: 2
}
)
electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: ['C:\\transfer\\agent.gbagent']
})
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsAgentPackageImport
)?.(event)
).resolves.toEqual(agentPackageInventory)
expect(agentPackageManager.importArchive).toHaveBeenCalledWith(
'C:\\transfer\\agent.gbagent'
)
electronMocks.showSaveDialog.mockResolvedValueOnce({
canceled: false,
filePath: 'C:\\transfer\\agent'
})
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsAgentPackageExport
)?.(event, { architecture: 'arm64' })
).resolves.toBeUndefined()
expect(agentPackageManager.exportArchive).toHaveBeenCalledWith(
'arm64',
'C:\\transfer\\agent.gbagent'
)
expect(
agentPackageManager.getExportArchiveName
).toHaveBeenCalledWith('arm64')
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsInspectDraftKey
)?.(
event,
{
hostId,
hostname: createInput.hostname,
port: createInput.port,
username: createInput.username
}
)
).resolves.toMatchObject({ fingerprintSha256: fingerprint })
expect(
sshHostService.inspectDraftHostKey
).toHaveBeenCalledWith({
hostId,
hostname: createInput.hostname,
port: createInput.port,
username: createInput.username
})
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsValidateAndSave
)?.(
event,
{
candidateId,
fingerprintSha256: fingerprint,
input: createInput
}
)
).resolves.toMatchObject({
host,
connection: { connected: true }
})
expect(sshHostService.validateAndSave).toHaveBeenCalledWith({
candidateId,
fingerprintSha256: fingerprint,
input: {
...createInput,
name: 'Build host'
}
})
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsDiscardCandidate
)?.(event, { candidateId })
).resolves.toBeUndefined()
expect(sshHostService.discardCandidate).toHaveBeenCalledWith(
candidateId
)
await expect(
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
event,
{ hostId }
)
).resolves.toEqual({
hostId,
deletedProjects: []
})
deleteProjectsReferencingSshHost.mockReturnValue([
{
id: '00000000-0000-4000-8000-000000000107',
name: 'Remote project'
}
])
await expect(
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
event,
{ hostId }
)
).resolves.toEqual({
hostId,
deletedProjects: [
{
id: '00000000-0000-4000-8000-000000000107',
name: 'Remote project'
}
]
})
expect(sshHostService.remove).toHaveBeenCalledTimes(2)
sshHostService.remove.mockRejectedValueOnce(
new Error('Host store unavailable')
)
await expect(
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
event,
{ hostId }
)
).rejects.toThrow('Host store unavailable')
expect(deleteProjectsReferencingSshHost).toHaveBeenCalledTimes(2)
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsRemoteEnvironment
)?.(event, { hostId })
).resolves.toEqual(remoteEnvironment)
expect(inspectRemoteEnvironment).toHaveBeenCalledWith(hostId)
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsRemoteEnvironment
)?.(event, { hostId: 'not-a-uuid' })
).rejects.toThrow()
expect(inspectRemoteEnvironment).toHaveBeenCalledOnce()
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsUpdateRemoteEnvironment
)?.(event, { hostId, method: 'remote-download' })
).resolves.toBeUndefined()
expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledWith(
webContents,
{ hostId, method: 'remote-download' },
expect.any(Function)
)
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.sshHostsRemoteEnvironmentUpdateProgress,
{
hostId,
method: 'remote-download',
phase: 'verifying'
}
)
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsUpdateRemoteEnvironment
)?.(event, {
hostId,
method: 'remote-download',
command: 'whoami'
})
).rejects.toThrow()
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsUpdateRemoteEnvironment
)?.(
{ sender: {}, senderFrame: webContents.mainFrame },
{ hostId, method: 'remote-download' }
)
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledOnce()
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsCancelRemoteEnvironmentUpdate
)?.(event, { hostId })
).resolves.toBeUndefined()
expect(remoteEnvironmentUpdateService.cancel).toHaveBeenCalledWith(
webContents,
hostId
)
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsValidateAndSave
)?.(event, {
candidateId: 'not-a-uuid',
fingerprintSha256: fingerprint,
input: { ...createInput, command: 'whoami' }
})
).rejects.toThrow()
expect(sshHostService.validateAndSave).toHaveBeenCalledOnce()
await expect(
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.({
sender: {},
senderFrame: webContents.mainFrame
})
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsAgentPackageInventory
)?.({
sender: {},
senderFrame: webContents.mainFrame
})
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
expect(
agentPackageManager.getSnapshot
).toHaveBeenCalledOnce()
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsBrowseDirectories
)?.(event, { hostId, path: '/srv' })
).resolves.toEqual(directoryListing)
expect(
sshHostDirectoryBrowser.listDirectories
).toHaveBeenCalledWith(
hostId,
'/srv',
expect.any(AbortSignal)
)
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsBrowseDirectories
)?.(event, { hostId, path: '../escape' })
).rejects.toThrow()
expect(
sshHostDirectoryBrowser.listDirectories
).toHaveBeenCalledOnce()
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsBrowseDirectories
)?.(
{ sender: {}, senderFrame: webContents.mainFrame },
{ hostId }
)
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
let browseSignal: AbortSignal | undefined
sshHostDirectoryBrowser.listDirectories.mockImplementationOnce(
async (...args): Promise<typeof directoryListing> => {
const signal = args[2]
if (!signal) {
throw new Error('Expected directory browse AbortSignal')
}
return new Promise<typeof directoryListing>((_resolve, reject) => {
browseSignal = signal
signal.addEventListener(
'abort',
() => reject(signal.reason),
{ once: true }
)
})
}
)
const activeBrowse = electronMocks.handlers.get(
ipcChannels.sshHostsBrowseDirectories
)?.(event, { hostId })
await vi.waitFor(() =>
expect(browseSignal).toBeInstanceOf(AbortSignal)
)
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsCancelDirectoryBrowse
)?.(event)
).resolves.toBeUndefined()
await expect(activeBrowse).rejects.toMatchObject({
name: 'AbortError'
})
const remoteDraft = {
intent: 'create',
draft: {
name: 'Remote project',
description: '',
runtimeSelection: { provider: 'opencode' },
hostId,
remoteRootPath: '/srv/project',
defaultWorkMode: 'ask'
}
}
await expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectSave
)?.(event, remoteDraft)
).resolves.toEqual(remoteProject)
expect(remoteProjectSaveService.save).toHaveBeenCalledWith(
webContents,
remoteDraft
)
await expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectCancelCurrent
)?.(event)
).resolves.toBeUndefined()
expect(
remoteProjectSaveService.cancelCurrent
).toHaveBeenCalledWith(webContents)
expect(selectedRuntimes.reset).not.toHaveBeenCalled()
expect(selectedRuntimes.reset).not.toHaveBeenCalled()
await expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectSave
)?.(
{ sender: {}, senderFrame: webContents.mainFrame },
remoteDraft
)
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
expect(removeAgentConnectionStatusListener).toHaveBeenCalledOnce()
expect(remoteProjectSaveService.dispose).toHaveBeenCalledOnce()
expect(
remoteEnvironmentUpdateService.dispose
).toHaveBeenCalledOnce()
})
it('blocks disabled and unavailable preview access before remote services', async () => {
const hostId = '00000000-0000-4000-8000-000000000105'
const candidateId = '00000000-0000-4000-8000-000000000106'
const projectId = '00000000-0000-4000-8000-000000000107'
const fingerprint = `SHA256:${'A'.repeat(43)}`
const remoteProject = {
id: projectId,
name: 'Remote project',
description: '',
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh' as const,
hostId,
remoteRootPath: '/srv/project'
},
defaultWorkMode: 'ask' as const,
kind: 'user' as const,
status: 'active' as const,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
const applicationSettingsStore = {
get: vi.fn(async () => ({ remoteProjectsEnabled: false }))
}
const sshHostService = {
getSnapshot: vi.fn(),
remove: vi.fn(),
inspectDraftHostKey: vi.fn(),
discardCandidate: vi.fn(),
validateAndSave: vi.fn()
}
const remoteProjectSaveService = {
activate: vi.fn(),
save: vi.fn(),
cancelCurrent: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const sshHostDirectoryBrowser = {
listDirectories: vi.fn()
}
const sshHostRemoteEnvironmentInspector = {
inspect: vi.fn()
}
const remoteEnvironmentUpdateService = {
update: vi.fn(),
cancel: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const executionSpaceResolver = {
resolveProject: vi.fn()
}
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
listSshHostProjectReferences: vi.fn(() => []),
deleteProjectsReferencingSshHost: vi.fn(() => []),
getProject: vi.fn(() => remoteProject),
updateProject: vi.fn(),
setProjectArchived: vi.fn(),
deleteProject: vi.fn()
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined),
undefined,
undefined,
undefined,
undefined,
applicationSettingsStore as never,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
sshHostService as never,
executionSpaceResolver as never,
remoteProjectSaveService as never,
sshHostDirectoryBrowser as never,
sshHostRemoteEnvironmentInspector as never,
undefined,
undefined,
undefined,
remoteEnvironmentUpdateService as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const createInput = {
name: 'Build host',
hostname: 'build.example.com',
port: 22,
username: 'builder',
authentication: 'system-agent',
password: { action: 'clear' }
}
const remoteDraft = {
intent: 'create',
draft: {
name: remoteProject.name,
description: '',
runtimeSelection: { provider: 'opencode' },
hostId,
remoteRootPath: '/srv/project',
defaultWorkMode: 'ask'
}
}
const disabledRequests: Array<[string, unknown?]> = [
[ipcChannels.sshHostsGet],
[ipcChannels.sshHostsRemove, { hostId }],
[
ipcChannels.sshHostsInspectDraftKey,
{
hostId,
hostname: createInput.hostname,
port: createInput.port,
username: createInput.username
}
],
[ipcChannels.sshHostsDiscardCandidate, { candidateId }],
[
ipcChannels.sshHostsValidateAndSave,
{ candidateId, fingerprintSha256: fingerprint, input: createInput }
],
[ipcChannels.sshHostsRemoteEnvironment, { hostId }],
[
ipcChannels.sshHostsUpdateRemoteEnvironment,
{ hostId, method: 'remote-download' }
],
[
ipcChannels.sshHostsBrowseDirectories,
{ hostId, path: '/srv' }
],
[ipcChannels.remoteProjectSave, remoteDraft]
]
for (const [channel, input] of disabledRequests) {
await expect(
electronMocks.handlers.get(channel)?.(event, input)
).rejects.toThrow('远程项目(技术预览)未启用')
}
await expect(
electronMocks.handlers.get(ipcChannels.projectsUpdate)?.(event, {
projectId,
input: {
name: remoteProject.name,
description: '',
rootPath: remoteProject.rootPath,
defaultWorkMode: 'ask'
}
})
).rejects.toThrow('远程项目(技术预览)未启用')
await expect(
electronMocks.handlers.get(ipcChannels.projectsSetArchived)?.(
event,
{ projectId, archived: true }
)
).rejects.toThrow('远程项目(技术预览)未启用')
await expect(
electronMocks.handlers.get(ipcChannels.projectsDelete)?.(event, {
projectId,
confirmation: remoteProject.name
})
).rejects.toThrow('远程项目(技术预览)未启用')
await expect(
electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.(
event,
{ projectId, path: 'README.md' }
)
).rejects.toThrow('远程项目(技术预览)未启用')
expect(sshHostService.getSnapshot).not.toHaveBeenCalled()
expect(sshHostService.remove).not.toHaveBeenCalled()
expect(sshHostService.inspectDraftHostKey).not.toHaveBeenCalled()
expect(sshHostService.discardCandidate).not.toHaveBeenCalled()
expect(sshHostService.validateAndSave).not.toHaveBeenCalled()
expect(
sshHostRemoteEnvironmentInspector.inspect
).not.toHaveBeenCalled()
expect(
sshHostDirectoryBrowser.listDirectories
).not.toHaveBeenCalled()
expect(remoteProjectSaveService.activate).not.toHaveBeenCalled()
expect(remoteProjectSaveService.save).not.toHaveBeenCalled()
expect(remoteEnvironmentUpdateService.update).not.toHaveBeenCalled()
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsCancelDirectoryBrowse
)?.(event)
).resolves.toBeUndefined()
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsCancelRemoteEnvironmentUpdate
)?.(event, { hostId })
).resolves.toBeUndefined()
expect(remoteEnvironmentUpdateService.cancel).toHaveBeenCalledWith(
webContents,
hostId
)
await expect(
electronMocks.handlers.get(
ipcChannels.remoteProjectCancelCurrent
)?.(event)
).resolves.toBeUndefined()
expect(remoteProjectSaveService.cancelCurrent).toHaveBeenCalledWith(
webContents
)
expect(
assistantDatabase.listSshHostProjectReferences
).not.toHaveBeenCalled()
expect(
assistantDatabase.deleteProjectsReferencingSshHost
).not.toHaveBeenCalled()
expect(assistantDatabase.updateProject).not.toHaveBeenCalled()
expect(assistantDatabase.setProjectArchived).not.toHaveBeenCalled()
expect(assistantDatabase.deleteProject).not.toHaveBeenCalled()
expect(executionSpaceResolver.resolveProject).not.toHaveBeenCalled()
applicationSettingsStore.get.mockRejectedValueOnce(
new Error('settings unavailable')
)
await expect(
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event)
).rejects.toThrow('远程项目(技术预览)未启用')
expect(sshHostService.getSnapshot).not.toHaveBeenCalled()
await dispose()
})
it('sends save progress only to a live owning WebContents', () => {
const owner = {
isDestroyed: vi.fn(() => false),
send: vi.fn(),
once: vi.fn(),
removeListener: vi.fn()
}
const progress = { phase: 'agent' as const }
sendRemoteProjectSaveProgress(owner as never, progress)
expect(owner.send).toHaveBeenCalledWith(
ipcChannels.remoteProjectSaveProgress,
progress
)
owner.isDestroyed.mockReturnValue(true)
sendRemoteProjectSaveProgress(owner as never, progress)
expect(owner.send).toHaveBeenCalledOnce()
})
it('sends validated update progress only to a live owning WebContents', () => {
const owner = {
id: 1,
isDestroyed: vi.fn(() => false),
send: vi.fn(),
on: vi.fn(),
removeListener: vi.fn()
}
const progress = {
hostId: '00000000-0000-4000-8000-000000000105',
method: 'remote-download' as const,
phase: 'installing-runtime' as const
}
sendRemoteEnvironmentUpdateProgress(owner as never, progress)
expect(owner.send).toHaveBeenCalledWith(
ipcChannels.sshHostsRemoteEnvironmentUpdateProgress,
progress
)
expect(() =>
sendRemoteEnvironmentUpdateProgress(owner as never, {
...progress,
phase: 'uploading'
} as never)
).toThrow()
owner.isDestroyed.mockReturnValue(true)
sendRemoteEnvironmentUpdateProgress(owner as never, progress)
expect(owner.send).toHaveBeenCalledOnce()
})
})
function runtimeUpdateFixture(
workspacePath: string,
provider: 'model' | 'opencode' | 'continue' = 'model'
) {
const profileId = '00000000-0000-4000-8000-000000000095'
const profile = {
id: profileId,
name: 'Default',
baseUrl: 'https://model.example/v1',
modelName: 'model',
protocol: 'openai-chat-completions' as const,
authentication: 'api-key' as const,
imageGenerationQuality: 'auto' as const,
apiKeyConfigured: true,
credentialSource: 'encrypted' as const
}
const publicSettings = {
provider,
modelBaseUrl: profile.baseUrl,
modelName: profile.modelName,
modelProtocol: profile.protocol,
modelAuthentication: profile.authentication,
imageGenerationQuality: profile.imageGenerationQuality,
opencodeBaseUrl: '',
opencodeEmbedded: true,
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat' as const,
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl: 'https://embedding.example/v1',
knowledgeEmbeddingModel: 'embedding',
knowledgeEmbeddingApiKeyConfigured: false,
knowledgeEmbeddingCredentialSource: 'none' as const,
knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: 'https://rerank.example/v1',
knowledgeRerankModel: 'rerank',
knowledgeRerankApiKeyConfigured: false,
knowledgeRerankCredentialSource: 'none' as const,
workspacePath,
apiKeyConfigured: true,
credentialSource: 'encrypted' as const,
modelProfiles: [profile],
defaultModelProfileId: profileId,
opencodeModelSource: { kind: 'platform' as const },
continueModelSource: { kind: 'platform' as const },
deepseekHarnessModelSource: { kind: 'platform' as const },
secureStorageAvailable: true,
toolApproval: 'always' as const
}
return {
publicSettings,
input: {
provider,
modelBaseUrl: profile.baseUrl,
modelName: profile.modelName,
modelProtocol: profile.protocol,
modelAuthentication: profile.authentication,
imageGenerationQuality: profile.imageGenerationQuality,
opencodeBaseUrl: '',
opencodeEmbedded: true,
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat' as const,
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl:
publicSettings.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel:
publicSettings.knowledgeEmbeddingModel,
knowledgeRerankEnabled: false,
knowledgeRerankEndpoint:
publicSettings.knowledgeRerankEndpoint,
knowledgeRerankModel: publicSettings.knowledgeRerankModel,
workspacePath,
apiKey: { action: 'keep' as const },
modelProfiles: [
{
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: { action: 'keep' as const }
}
],
defaultModelProfileId: profileId,
opencodeModelSource: { kind: 'platform' as const },
continueModelSource: { kind: 'platform' as const },
deepseekHarnessModelSource: { kind: 'platform' as const },
toolApproval: 'always' as const
}
}
}
describe('registerIpcHandlers lifecycle tracking', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
channelMocks.stop.mockResolvedValue(undefined)
})
it('waits for a pending settings update and Runtime reload during cleanup', async () => {
let releaseUpdate!: () => void
const updateReleased = new Promise<void>((resolve) => {
releaseUpdate = resolve
})
const workspace = await mkdtemp(
join(tmpdir(), 'goodbuddy-ipc-settings-')
)
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const savedSettings = {
provider: 'model',
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'api-key',
imageGenerationQuality: 'auto',
opencodeBaseUrl: '',
opencodeEmbedded: true,
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat',
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
knowledgeEmbeddingModel: 'nomic-embed-text',
knowledgeEmbeddingApiKeyConfigured: false,
knowledgeEmbeddingCredentialSource: 'none',
knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
knowledgeRerankModel: 'rerank-v3.5',
knowledgeRerankApiKeyConfigured: false,
knowledgeRerankCredentialSource: 'none',
workspacePath: workspace,
apiKeyConfigured: false,
credentialSource: 'none',
modelProfiles: [],
defaultModelProfileId: '00000000-0000-4000-8000-000000000001',
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' },
secureStorageAvailable: true,
toolApproval: 'always'
}
const update = vi.fn(async () => {
await updateReleased
return savedSettings
})
let releaseReload!: () => void
const reloadReleased = new Promise<void>((resolve) => {
releaseReload = resolve
})
const onRuntimeSettingsChanged = vi.fn(async () => {
await reloadReleased
})
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{
captureRollback: vi.fn(async () => ({
publicSettings: savedSettings,
restore: vi.fn(async () => savedSettings)
})),
update
} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
repairConversationRuntimeSelections: vi.fn()
} as never,
{ clear: vi.fn() } as never,
{} as never,
onRuntimeSettingsChanged
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const input = {
provider: savedSettings.provider,
modelBaseUrl: savedSettings.modelBaseUrl,
modelName: savedSettings.modelName,
modelProtocol: savedSettings.modelProtocol,
modelAuthentication: savedSettings.modelAuthentication,
imageGenerationQuality: savedSettings.imageGenerationQuality,
opencodeBaseUrl: savedSettings.opencodeBaseUrl,
opencodeEmbedded: savedSettings.opencodeEmbedded,
opencodeBinaryPath: savedSettings.opencodeBinaryPath,
opencodeConfigPath: savedSettings.opencodeConfigPath,
continueBinaryPath: savedSettings.continueBinaryPath,
continueConfigPath: savedSettings.continueConfigPath,
continueMode: savedSettings.continueMode,
knowledgeEmbeddingEnabled:
savedSettings.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl:
savedSettings.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: savedSettings.knowledgeEmbeddingModel,
knowledgeRerankEnabled: savedSettings.knowledgeRerankEnabled,
knowledgeRerankEndpoint: savedSettings.knowledgeRerankEndpoint,
knowledgeRerankModel: savedSettings.knowledgeRerankModel,
workspacePath: savedSettings.workspacePath,
apiKey: { action: 'keep' as const },
toolApproval: savedSettings.toolApproval
}
try {
const pendingUpdate = Promise.resolve(
electronMocks.handlers.get(ipcChannels.runtimeSettingsUpdate)?.(
event,
input
)
)
await vi.waitFor(() => expect(update).toHaveBeenCalledOnce())
let cleanupComplete = false
const cleanup = dispose().then(() => {
cleanupComplete = true
})
await Promise.resolve()
expect(cleanupComplete).toBe(false)
releaseUpdate()
await vi.waitFor(() =>
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
)
expect(cleanupComplete).toBe(false)
releaseReload()
await expect(pendingUpdate).resolves.toBe(savedSettings)
await cleanup
expect(cleanupComplete).toBe(true)
} finally {
releaseUpdate()
releaseReload()
await rm(workspace, { recursive: true, force: true })
}
})
it('restores settings and preserves activation plus rollback failures', async () => {
const workspace = await mkdtemp(
join(tmpdir(), 'goodbuddy-ipc-settings-rollback-')
)
const profileId = '00000000-0000-4000-8000-000000000091'
const profile = {
id: profileId,
name: 'Default',
baseUrl: 'https://model.example/v1',
modelName: 'model',
protocol: 'openai-chat-completions' as const,
authentication: 'api-key' as const,
imageGenerationQuality: 'auto' as const,
apiKeyConfigured: true,
credentialSource: 'encrypted' as const
}
const previousSettings = {
provider: 'model' as const,
modelBaseUrl: profile.baseUrl,
modelName: profile.modelName,
modelProtocol: profile.protocol,
modelAuthentication: profile.authentication,
imageGenerationQuality: profile.imageGenerationQuality,
opencodeBaseUrl: '',
opencodeEmbedded: true,
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat' as const,
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl: 'https://embedding.example/v1',
knowledgeEmbeddingModel: 'embedding',
knowledgeEmbeddingApiKeyConfigured: false,
knowledgeEmbeddingCredentialSource: 'none' as const,
knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: 'https://rerank.example/v1',
knowledgeRerankModel: 'rerank',
knowledgeRerankApiKeyConfigured: false,
knowledgeRerankCredentialSource: 'none' as const,
workspacePath: workspace,
apiKeyConfigured: true,
credentialSource: 'encrypted' as const,
modelProfiles: [profile],
defaultModelProfileId: profileId,
opencodeModelSource: { kind: 'platform' as const },
continueModelSource: { kind: 'platform' as const },
deepseekHarnessModelSource: { kind: 'platform' as const },
secureStorageAvailable: true,
toolApproval: 'always' as const
}
const candidateSettings = {
...previousSettings,
provider: 'opencode' as const
}
const update = vi.fn().mockResolvedValue(candidateSettings)
const restore = vi.fn(async () => previousSettings)
const activationError = new Error('candidate activation failed')
const rollbackError = new Error('rollback activation failed')
const onRuntimeSettingsChanged = vi
.fn()
.mockRejectedValueOnce(activationError)
.mockRejectedValueOnce(rollbackError)
const repairs = vi.fn()
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{
captureRollback: vi.fn(async () => ({
publicSettings: previousSettings,
restore
})),
update
} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
repairConversationRuntimeSelections: repairs
} as never,
{ clear: vi.fn() } as never,
{} as never,
onRuntimeSettingsChanged
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const input = {
provider: candidateSettings.provider,
modelBaseUrl: profile.baseUrl,
modelName: profile.modelName,
modelProtocol: profile.protocol,
modelAuthentication: profile.authentication,
imageGenerationQuality: profile.imageGenerationQuality,
opencodeBaseUrl: '',
opencodeEmbedded: true,
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat',
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl:
previousSettings.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel:
previousSettings.knowledgeEmbeddingModel,
knowledgeRerankEnabled: false,
knowledgeRerankEndpoint:
previousSettings.knowledgeRerankEndpoint,
knowledgeRerankModel:
previousSettings.knowledgeRerankModel,
workspacePath: workspace,
apiKey: { action: 'keep' },
modelProfiles: [
{
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: { action: 'keep' }
}
],
defaultModelProfileId: profileId,
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' },
deepseekHarnessModelSource: { kind: 'platform' },
toolApproval: 'always'
}
try {
const result = Promise.resolve(
electronMocks.handlers.get(
ipcChannels.runtimeSettingsUpdate
)?.(event, input)
)
await expect(result).rejects.toMatchObject({
errors: [activationError, rollbackError]
})
expect(update).toHaveBeenCalledOnce()
expect(restore).toHaveBeenCalledOnce()
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(2)
expect(repairs).not.toHaveBeenCalled()
} finally {
await dispose()
await rm(workspace, { recursive: true, force: true })
}
})
it('serializes failed settings, customization, and a later successful candidate', async () => {
const workspace = await mkdtemp(
join(tmpdir(), 'goodbuddy-ipc-settings-overlap-')
)
const initial = runtimeUpdateFixture(workspace, 'model')
const candidateA = runtimeUpdateFixture(workspace, 'opencode')
const candidateB = runtimeUpdateFixture(workspace, 'continue')
let currentSettings = initial.publicSettings
const initialCustomization = {
opencode: {},
continue: { presets: [] }
}
const candidateCustomization = {
opencode: { defaultAgent: 'candidate-agent' },
continue: { presets: [] }
}
let currentCustomization = initialCustomization
const captureRollback = vi.fn(async () => {
const snapshot = currentSettings
return {
publicSettings: snapshot,
restore: vi.fn(async () => {
currentSettings = snapshot
return snapshot
})
}
})
const update = vi.fn(
async (input: { provider: 'model' | 'opencode' | 'continue' }) => {
currentSettings =
input.provider === 'opencode'
? candidateA.publicSettings
: candidateB.publicSettings
return currentSettings
}
)
const updateRuntimeCustomization = vi.fn(
async (settings: typeof candidateCustomization) => {
currentCustomization = settings
return settings
}
)
let releaseCandidateA!: () => void
const candidateABlocked = new Promise<void>((resolve) => {
releaseCandidateA = resolve
})
const candidateAError = new Error('candidate A activation failed')
let activationCount = 0
const onRuntimeSettingsChanged = vi.fn(async () => {
activationCount += 1
if (activationCount === 1) {
await candidateABlocked
throw candidateAError
}
})
const repairs = vi.fn(() => [])
const reportRepairs = vi.fn()
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{
captureRollback,
update,
getRuntimeCustomization: vi.fn(
async () => currentCustomization
),
updateRuntimeCustomization
} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
repairConversationRuntimeSelections: repairs
} as never,
{ clear: vi.fn() } as never,
{} as never,
onRuntimeSettingsChanged,
undefined,
undefined,
undefined,
{
reportRuntimeSelectionRepairs: reportRepairs
} as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
try {
const updateA = Promise.resolve(
electronMocks.handlers.get(
ipcChannels.runtimeSettingsUpdate
)?.(event, candidateA.input)
)
await vi.waitFor(() =>
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
)
const customizationUpdate = Promise.resolve(
electronMocks.handlers.get(
ipcChannels.runtimeCustomizationUpdate
)?.(event, candidateCustomization)
)
const updateB = Promise.resolve(
electronMocks.handlers.get(
ipcChannels.runtimeSettingsUpdate
)?.(event, candidateB.input)
)
await Promise.resolve()
expect(captureRollback).toHaveBeenCalledOnce()
expect(update).toHaveBeenCalledOnce()
releaseCandidateA()
await expect(updateA).rejects.toBe(candidateAError)
await expect(customizationUpdate).resolves.toEqual(
candidateCustomization
)
await expect(updateB).resolves.toMatchObject({
provider: 'continue'
})
expect(currentSettings.provider).toBe('continue')
expect(currentCustomization).toEqual(candidateCustomization)
expect(captureRollback).toHaveBeenCalledTimes(2)
expect(update).toHaveBeenCalledTimes(2)
expect(updateRuntimeCustomization).toHaveBeenCalledOnce()
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(4)
expect(repairs).toHaveBeenCalledOnce()
expect(repairs).toHaveBeenCalledWith(
expect.objectContaining({ provider: 'continue' })
)
expect(reportRepairs).toHaveBeenCalledOnce()
} finally {
releaseCandidateA()
await dispose()
await rm(workspace, { recursive: true, force: true })
}
})
})
describe('registerIpcHandlers knowledge snapshot ontology', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('exposes per-library ontology settings and rebuild state', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
const knowledgeService = {
snapshot: vi.fn(() => ({
libraries: [
{
id: libraryId,
name: 'Ontology',
description: '',
storageMode: 'reference',
graphEnabled: true,
graphStrategy: 'rules',
sourceCount: 0,
documentCount: 0,
indexedDocumentCount: 0,
retrievalSettings: {},
chunkingSettings: {},
chunkingRebuildRequired: false,
ontologySettings: defaultKnowledgeOntologySettings,
ontologyRebuildRequired: true,
updatedAt: '2026-08-12T00:00:00.000Z'
}
],
sources: [],
documents: [],
entities: [],
relations: [],
evidence: [],
tasks: []
}))
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
knowledgeService as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined)
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
expect(
electronMocks.handlers.get(ipcChannels.knowledgeSnapshot)?.(
event,
libraryId
)
).toMatchObject({
libraries: [
{
id: libraryId,
ontologySettings: defaultKnowledgeOntologySettings,
ontologyRebuildRequired: true
}
]
})
await dispose()
})
})
describe('registerIpcHandlers knowledge embedding index', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('validates and forwards library-scoped index actions', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
const jobId = '22222222-2222-4222-8222-222222222222'
const snapshot = {
knowledgeBaseId: libraryId,
enabled: true,
configuration: {
provider: 'openai-compatible',
model: 'embed-v1',
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
credentialConfigured: false
},
coverage: { total: 2, indexed: 1, missing: 1, error: 0 },
indexStatus: { job: null }
}
const knowledgeService = {
getEmbeddingIndexSnapshot: vi.fn(async () => snapshot),
rebuildEmbeddingIndex: vi.fn(async () => snapshot),
cancelEmbeddingIndex: vi.fn(async () => true)
}
const settingsStore = {
getResolvedSettings: vi.fn(async () => ({
knowledgeEmbeddingEnabled: true
})),
getPublicSettings: vi.fn(async () => ({
knowledgeEmbeddingModel: 'embed-v1',
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
knowledgeEmbeddingApiKeyConfigured: false
}))
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
settingsStore as never,
{} as never,
{ clear: vi.fn() } as never,
knowledgeService as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined)
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
await expect(
electronMocks.handlers.get(
ipcChannels.knowledgeEmbeddingIndexGet
)?.(event, { knowledgeBaseId: libraryId })
).resolves.toEqual(snapshot)
expect(
knowledgeService.getEmbeddingIndexSnapshot
).toHaveBeenCalledWith(
libraryId,
snapshot.configuration
)
await expect(
electronMocks.handlers.get(
ipcChannels.knowledgeEmbeddingIndexRebuild
)?.(event, { knowledgeBaseId: libraryId })
).resolves.toEqual(snapshot)
expect(knowledgeService.rebuildEmbeddingIndex).toHaveBeenCalledWith(
libraryId,
snapshot.configuration
)
await expect(
electronMocks.handlers.get(
ipcChannels.knowledgeEmbeddingIndexCancel
)?.(event, { knowledgeBaseId: libraryId, jobId })
).resolves.toBe(true)
expect(knowledgeService.cancelEmbeddingIndex).toHaveBeenCalledWith(
libraryId,
jobId
)
await expect(
electronMocks.handlers.get(
ipcChannels.knowledgeEmbeddingIndexGet
)?.(event, { knowledgeBaseId: 'not-a-uuid' })
).rejects.toThrow()
await expect(
electronMocks.handlers.get(
ipcChannels.knowledgeEmbeddingIndexGet
)?.(
{ sender: {}, senderFrame: webContents.mainFrame },
{ knowledgeBaseId: libraryId }
)
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
})
})
describe('registerIpcHandlers knowledge task actions', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('validates and forwards bounded cancel and retry actions', async () => {
const taskId = '33333333-3333-4333-8333-333333333333'
const knowledgeService = {
cancelTask: vi.fn(async () => true),
retryTask: vi.fn(async () => undefined)
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
knowledgeService as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined)
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
await expect(
electronMocks.handlers.get(
ipcChannels.knowledgeTaskCancel
)?.(event, { taskId })
).resolves.toBe(true)
expect(knowledgeService.cancelTask).toHaveBeenCalledWith(taskId)
await expect(
electronMocks.handlers.get(
ipcChannels.knowledgeTaskRetry
)?.(event, { taskId })
).resolves.toBeUndefined()
expect(knowledgeService.retryTask).toHaveBeenCalledWith(taskId)
await expect(
electronMocks.handlers.get(
ipcChannels.knowledgeTaskCancel
)?.(event, { taskId: 'not-a-uuid' })
).rejects.toThrow()
await expect(
electronMocks.handlers.get(
ipcChannels.knowledgeTaskRetry
)?.(
{ sender: {}, senderFrame: webContents.mainFrame },
{ taskId }
)
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
})
})
describe('registerIpcHandlers model ZIP dialogs', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('imports and exports speech and OCR ZIPs through trusted dialogs', async () => {
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const speechSnapshot = {
catalog: [],
installed: [],
operations: []
}
const speechModelManager = {
rootDirectory: 'C:\\models\\speech',
importArchive: vi.fn(async () => speechSnapshot),
exportArchive: vi.fn(async () => undefined),
getSnapshot: vi.fn(async () => speechSnapshot),
cancel: vi.fn()
}
const ocrSnapshot = {
settings: {},
models: {
catalog: [],
installed: [],
operations: []
}
}
const documentParsingService = {
snapshot: vi.fn(async () => ocrSnapshot)
}
const documentOcrModelManager = {
importArchive: vi.fn(async () => undefined),
exportArchive: vi.fn(async () => undefined)
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
speechModelManager as never,
undefined,
undefined,
undefined,
undefined,
undefined,
documentParsingService as never,
documentOcrModelManager as never
)
await expect(
electronMocks.handlers.get(
ipcChannels.speechModelsImportArchive
)?.(event, { modelId: 'speech-model' })
).resolves.toBeUndefined()
expect(speechModelManager.importArchive).not.toHaveBeenCalled()
electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: ['C:\\transfer\\speech.zip']
})
await expect(
electronMocks.handlers.get(
ipcChannels.speechModelsImportArchive
)?.(event, { modelId: 'speech-model' })
).resolves.toBe(speechSnapshot)
expect(electronMocks.showOpenDialog).toHaveBeenLastCalledWith(
window,
expect.objectContaining({
properties: ['openFile'],
filters: [
{
name: 'GoodBuddy 模型 ZIP',
extensions: ['zip']
}
]
})
)
expect(speechModelManager.importArchive).toHaveBeenCalledWith(
'speech-model',
'C:\\transfer\\speech.zip'
)
electronMocks.showSaveDialog.mockResolvedValueOnce({
canceled: false,
filePath: 'C:\\transfer\\speech-model'
})
await expect(
electronMocks.handlers.get(
ipcChannels.speechModelsExportArchive
)?.(event, { modelId: 'speech-model' })
).resolves.toBe(speechSnapshot)
expect(speechModelManager.exportArchive).toHaveBeenCalledWith(
'speech-model',
'C:\\transfer\\speech-model.zip'
)
electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: ['C:\\transfer\\ocr.zip']
})
await expect(
electronMocks.handlers.get(
ipcChannels.documentOcrModelsImportArchive
)?.(event, { modelId: 'ocr-model' })
).resolves.toBe(ocrSnapshot)
expect(documentOcrModelManager.importArchive).toHaveBeenCalledWith(
'ocr-model',
'C:\\transfer\\ocr.zip'
)
electronMocks.showSaveDialog.mockResolvedValueOnce({
canceled: false,
filePath: 'C:\\transfer\\ocr-model.ZIP'
})
await expect(
electronMocks.handlers.get(
ipcChannels.documentOcrModelsExportArchive
)?.(event, { modelId: 'ocr-model' })
).resolves.toBe(ocrSnapshot)
expect(documentOcrModelManager.exportArchive).toHaveBeenCalledWith(
'ocr-model',
'C:\\transfer\\ocr-model.ZIP'
)
await expect(
electronMocks.handlers.get(
ipcChannels.speechModelsExportArchive
)?.(
{
sender: {},
senderFrame: webContents.mainFrame
},
{ modelId: 'speech-model' }
)
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(
electronMocks.handlers.get(
ipcChannels.documentOcrModelsImportArchive
)?.(event, {})
).rejects.toThrow()
await dispose()
})
})
describe('registerIpcHandlers document parsing', () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
electronMocks.handlers.clear()
vi.clearAllMocks()
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
it('tests an explicit scenario and parses artifact PDFs through the shared service', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-ipc-document-parsing-')
)
temporaryDirectories.push(directory)
const diagnosticPath = join(directory, 'diagnostic.pdf')
const artifactPath = join(directory, 'artifact.pdf')
const oversizedArtifactPath = join(directory, 'oversized.pdf')
await writeFile(diagnosticPath, 'diagnostic')
await writeFile(artifactPath, 'artifact')
await writeFile(
oversizedArtifactPath,
Buffer.alloc(20 * 1024 * 1024 + 1)
)
const diagnostic = {
fileName: 'diagnostic.pdf',
sourceFormat: 'PDF',
pageCount: 1,
ocrPageCount: 0,
characterCount: 4,
method: 'native',
durationMs: 1,
preview: 'text',
warnings: []
}
const documentParsingService = {
diagnose: vi.fn(async () => diagnostic),
parse: vi.fn(async () => ({
title: 'artifact',
sourceFormat: '.pdf',
content: 'parsed artifact',
sections: [
{
locator: '第 1 页',
content: 'parsed artifact',
method: 'native'
}
],
pageCount: 1,
warnings: []
}))
}
const createInlineArtifact = vi.fn((input) => input)
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
createInlineArtifact
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
documentParsingService as never
)
electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: [diagnosticPath]
})
await expect(
electronMocks.handlers.get(
ipcChannels.documentParsingTest
)?.(event, { purpose: 'knowledge-index' })
).resolves.toBe(diagnostic)
expect(documentParsingService.diagnose).toHaveBeenCalledWith(
'diagnostic.pdf',
expect.any(Buffer),
'knowledge-index'
)
await expect(
electronMocks.handlers.get(
ipcChannels.documentParsingTest
)?.(event, { purpose: 'diagnostic' })
).rejects.toThrow()
electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: [artifactPath]
})
await expect(
electronMocks.handlers.get(
ipcChannels.artifactsImportFiles
)?.(event)
).resolves.toEqual([
expect.objectContaining({
title: 'artifact.pdf',
content: '## 第 1 页\n\nparsed artifact'
})
])
expect(documentParsingService.parse).toHaveBeenCalledWith(
'artifact.pdf',
expect.any(Buffer),
'artifact-import'
)
electronMocks.showOpenDialog.mockResolvedValueOnce({
canceled: false,
filePaths: [oversizedArtifactPath]
})
await expect(
electronMocks.handlers.get(
ipcChannels.artifactsImportFiles
)?.(event)
).rejects.toThrow('超过大小限制')
expect(documentParsingService.parse).toHaveBeenCalledOnce()
await dispose()
})
})
describe('registerIpcHandlers connection tests', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('tests a resolved model profile without invoking the selected Continue Runtime', async () => {
const profileId = '00000000-0000-4000-8000-000000000001'
const profile = {
id: profileId,
name: '默认模型',
baseUrl: 'https://models.example',
modelName: 'good-model',
protocol: 'anthropic-messages',
authentication: 'api-key',
imageGenerationQuality: 'auto',
apiKey: 'main-only-secret' as string | undefined
}
const resolvedSettings = {
provider: 'continue',
workspacePath: 'C:\\Workspace',
modelProfiles: [profile],
defaultModelProfileId: profileId
}
const modelRuntime = {
testConnection: vi.fn(async () => ({
id: 'model',
label: 'good-model',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})),
getStatus: vi.fn(),
dispose: vi.fn(async () => undefined)
}
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(
modelRuntime
)
const continueRuntime = {
testConnection: vi.fn(async () => {
throw new Error('Continue 配置不可用')
}),
getStatus: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const getResolvedSettings = vi.fn(async () => resolvedSettings)
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const contextManager = { clear: vi.fn() }
const approvalBroker = { clear: vi.fn() }
const dispose = registerIpcHandlers(
window as never,
continueRuntime as never,
'CommandOrControl+Shift+Space',
{ getResolvedSettings } as never,
{} as never,
contextManager as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
approvalBroker as never,
{} as never,
vi.fn(async () => {})
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeSettingsTestModel
)?.(event, profileId)
).resolves.toMatchObject({
id: 'model',
label: 'good-model',
available: true
})
expect(getResolvedSettings).toHaveBeenCalledOnce()
expect(
runtimeFactoryMocks.createModelProfileRuntime
).toHaveBeenCalledWith('C:\\Workspace', resolvedSettings, profile)
expect(modelRuntime.testConnection).toHaveBeenCalledOnce()
expect(modelRuntime.dispose).toHaveBeenCalledOnce()
expect(continueRuntime.testConnection).not.toHaveBeenCalled()
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeSettingsTestModel
)?.(event, 'not-a-profile-id')
).rejects.toThrow()
expect(getResolvedSettings).toHaveBeenCalledOnce()
getResolvedSettings.mockResolvedValueOnce({
...resolvedSettings,
modelProfiles: [{ ...profile, apiKey: undefined }]
})
runtimeFactoryMocks.createModelProfileRuntime.mockClear()
modelRuntime.testConnection.mockClear()
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeSettingsTestModel
)?.(event, profileId)
).rejects.toThrow('模型连接“默认模型”未配置 API Key')
expect(getResolvedSettings).toHaveBeenCalledTimes(2)
expect(
runtimeFactoryMocks.createModelProfileRuntime
).not.toHaveBeenCalled()
expect(modelRuntime.testConnection).not.toHaveBeenCalled()
const noAuthProfile = {
...profile,
authentication: 'none',
apiKey: undefined
}
const noAuthSettings = {
...resolvedSettings,
modelProfiles: [noAuthProfile]
}
getResolvedSettings.mockResolvedValueOnce(noAuthSettings)
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeSettingsTestModel
)?.(event, profileId)
).resolves.toMatchObject({ available: true })
expect(
runtimeFactoryMocks.createModelProfileRuntime
).toHaveBeenCalledWith(
'C:\\Workspace',
noAuthSettings,
noAuthProfile
)
expect(modelRuntime.testConnection).toHaveBeenCalledOnce()
await dispose()
})
it('validates and tests the selected OpenCode or Continue Runtime', async () => {
const selectedRuntimes = {
getRuntime: vi.fn(),
getStatus: vi.fn(),
testStatus: vi.fn(async () => ({
id: 'opencode',
label: 'OpenCode',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})),
releaseConversation: vi.fn(async () => undefined)
}
const fallbackRuntime = {
testConnection: vi.fn(async () => {
throw new Error('不应测试旧的全局 Runtime')
}),
getStatus: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
fallbackRuntime as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {}),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
selectedRuntimes as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const selection = {
provider: 'opencode' as const,
profileId: '00000000-0000-4000-8000-000000000001'
}
await expect(
electronMocks.handlers.get(ipcChannels.runtimeSettingsTest)?.(
event,
selection
)
).resolves.toMatchObject({ id: 'opencode', available: true })
expect(selectedRuntimes.testStatus).toHaveBeenCalledWith(selection)
expect(fallbackRuntime.testConnection).not.toHaveBeenCalled()
await expect(
electronMocks.handlers.get(ipcChannels.runtimeSettingsTest)?.(
event,
{ provider: 'opencode', profileId: 'not-a-uuid' }
)
).rejects.toThrow()
expect(selectedRuntimes.testStatus).toHaveBeenCalledOnce()
await dispose()
})
})
describe('registerIpcHandlers Runtime config actions', () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
electronMocks.handlers.clear()
vi.clearAllMocks()
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
it('opens only configured files or the fixed Runtime config directory', async () => {
const temporaryDirectory = await mkdtemp(
join(tmpdir(), 'goodbuddy-runtime-config-')
)
temporaryDirectories.push(temporaryDirectory)
const configPath = join(temporaryDirectory, 'config.yaml')
await writeFile(configPath, 'name: Test', 'utf8')
const getPublicSettings = vi.fn(async () => ({
opencodeConfigPath: '',
continueConfigPath: process.execPath,
configured: {
opencodeConfigPath: '',
continueConfigPath: configPath
}
}))
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{ getPublicSettings } as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {})
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const canonicalConfigPath = await realpath(configPath)
await electronMocks.handlers.get(
ipcChannels.runtimeSettingsOpenConfig
)?.(event, {
runtime: 'continue',
action: 'open-file'
})
expect(electronMocks.openPath).toHaveBeenCalledWith(
canonicalConfigPath
)
await electronMocks.handlers.get(
ipcChannels.runtimeSettingsOpenConfig
)?.(event, {
runtime: 'continue',
action: 'show-file'
})
expect(electronMocks.showItemInFolder).toHaveBeenCalledWith(
canonicalConfigPath
)
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeSettingsOpenConfig
)?.(event, {
runtime: 'continue',
action: 'open-file',
path: join(temporaryDirectory, 'attacker-controlled.yaml')
})
).rejects.toThrow()
expect(getPublicSettings).toHaveBeenCalledTimes(2)
getPublicSettings.mockResolvedValueOnce({
opencodeConfigPath: '',
continueConfigPath: configPath,
configured: {
opencodeConfigPath: '',
continueConfigPath: process.execPath
}
})
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeSettingsOpenConfig
)?.(event, {
runtime: 'continue',
action: 'open-file'
})
).rejects.toThrow('Runtime 配置文件类型不支持直接打开')
expect(electronMocks.openPath).toHaveBeenCalledTimes(1)
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME
process.env.XDG_CONFIG_HOME = temporaryDirectory
try {
await electronMocks.handlers.get(
ipcChannels.runtimeSettingsOpenConfig
)?.(event, {
runtime: 'opencode',
action: 'open-directory'
})
expect(electronMocks.openPath).toHaveBeenLastCalledWith(
await realpath(join(temporaryDirectory, 'opencode'))
)
} finally {
if (previousXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME
} else {
process.env.XDG_CONFIG_HOME = previousXdgConfigHome
}
}
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeSettingsOpenConfig
)?.(
{
sender: {},
senderFrame: webContents.mainFrame
},
{
runtime: 'continue',
action: 'open-directory'
}
)
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
})
})
describe('registerIpcHandlers window controls', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('restricts custom chrome controls to the trusted main window', async () => {
let maximized = false
const listeners = new Map<string, () => void>()
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => maximized),
minimize: vi.fn(),
maximize: vi.fn(() => {
maximized = true
}),
unmaximize: vi.fn(() => {
maximized = false
}),
close: vi.fn(),
on: vi.fn((name: string, listener: () => void) => {
listeners.set(name, listener)
}),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {})
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
electronMocks.handlers.get(ipcChannels.windowMinimize)?.(event)
electronMocks.handlers.get(
ipcChannels.windowToggleMaximize
)?.(event)
listeners.get('maximize')?.()
electronMocks.handlers.get(ipcChannels.windowClose)?.(event)
expect(window.minimize).toHaveBeenCalledOnce()
expect(window.maximize).toHaveBeenCalledOnce()
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.windowMaximizedChanged,
true
)
expect(window.close).toHaveBeenCalledOnce()
expect(() =>
electronMocks.handlers
.get(ipcChannels.windowIsMaximized)
?.({
sender: {},
senderFrame: webContents.mainFrame
})
).toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
expect(window.removeListener).toHaveBeenCalledWith(
'maximize',
listeners.get('maximize')
)
})
})
describe('registerIpcHandlers workspace files', () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
electronMocks.handlers.clear()
vi.clearAllMocks()
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
it('resolves the project root and validates file requests', async () => {
const rootPath = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-files-'))
temporaryDirectories.push(rootPath)
await writeFile(join(rootPath, 'README.md'), '# GoodBuddy\n')
const projectId = '00000000-0000-4000-8000-000000000101'
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getProject: vi.fn(() => ({ id: projectId, rootPath }))
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {}),
undefined,
undefined,
undefined,
undefined,
{
get: vi.fn(async () => {
throw new Error('settings unavailable')
})
} as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const list = await electronMocks.handlers.get(
ipcChannels.workspaceDirectoryList
)?.(event, { projectId, path: '' })
const preview = await electronMocks.handlers.get(
ipcChannels.workspaceFileRead
)?.(event, { projectId, path: 'README.md' })
expect(list).toMatchObject({
entries: [
{ name: 'README.md', path: 'README.md', type: 'file' }
]
})
expect(preview).toMatchObject({
path: 'README.md',
content: '# GoodBuddy\n',
mimeType: 'text/markdown'
})
await expect(
electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.(event, {
projectId,
path: '../outside.txt'
})
).rejects.toThrow('路径必须是工作区内的相对路径')
expect(assistantDatabase.getProject).toHaveBeenCalledWith(projectId)
await dispose()
})
it('fails remote workspace requests before filesystem, Git, or shell access', async () => {
const projectId = '00000000-0000-4000-8000-000000000102'
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getProject: vi.fn(() => ({
id: projectId,
name: 'Remote',
description: '',
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000202',
remoteRootPath: '/srv/project'
},
defaultWorkMode: 'ask',
kind: 'user',
status: 'active',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}))
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {}),
undefined,
undefined,
undefined,
undefined,
{
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
} as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
for (const [channel, input] of [
[ipcChannels.workspaceChangesGet, projectId],
[
ipcChannels.workspaceDirectoryList,
{ projectId, path: '' }
],
[
ipcChannels.workspaceFileRead,
{ projectId, path: 'README.md' }
],
[
ipcChannels.workspacePathOpen,
{ projectId, path: 'README.md', type: 'file' }
]
] as const) {
await expect(
electronMocks.handlers.get(channel)?.(event, input)
).rejects.toThrow('远程执行空间尚不可用')
}
expect(electronMocks.openPath).not.toHaveBeenCalled()
await dispose()
})
})
describe('registerIpcHandlers token usage', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('returns the database token summary to a trusted renderer', async () => {
const summary = {
totals: {
callCount: 2,
input: 120,
output: 30,
cacheRead: 10,
cacheWrite: 5,
totalTokens: 165
},
records: []
}
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getTokenUsageSummary: vi.fn(() => summary)
}
const webContents = {
mainFrame: {
url: 'file:///goodbuddy/index.html'
},
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {})
)
const handler = electronMocks.handlers.get(
ipcChannels.tokenUsageSummary
)
expect(handler).toBeDefined()
expect(
handler?.({
sender: webContents,
senderFrame: webContents.mainFrame
})
).toBe(summary)
expect(assistantDatabase.getTokenUsageSummary).toHaveBeenCalledOnce()
await dispose()
})
})
describe('registerIpcHandlers local conversation persistence', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('validates and forwards incremental saves, branches, and explicit deletions', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000301'
const queuedAttachmentId =
'00000000-0000-4000-8000-000000000303'
const queuedItem = {
id: '00000000-0000-4000-8000-000000000304',
conversationId,
source: 'user' as const,
label: '待删除消息',
createdAt: '2026-08-20T09:01:00.000Z'
}
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => [queuedItem]),
listPendingConversationQueueIds: vi.fn(() => [conversationId]),
getConversationUserQueuePayloadJson: vi.fn(() =>
JSON.stringify({
input: {
conversationId,
runtimeSelection: { provider: 'auto' },
workMode: 'ask',
includeMemoryContext: true,
prompt: queuedItem.label,
attachments: [
{
id: queuedAttachmentId,
name: 'queued.txt',
size: 6,
preview: 'queued',
kind: 'text'
}
],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto'
},
serializedContexts: '[]'
})
),
saveLocalConversations: vi.fn(),
branchLocalConversation: vi.fn((input) => ({
id: '00000000-0000-4000-8000-000000000305',
branch: {
sourceConversationId: input.sourceConversationId,
sourceTitle: '增量会话'
},
title: input.title,
updatedAt: 2,
messages: []
})),
deleteLocalConversation: vi.fn(() => true)
}
const contextManager = {
clear: vi.fn(),
remove: vi.fn()
}
const webContents = {
mainFrame: {
url: 'file:///goodbuddy/index.html'
},
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
contextManager as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {})
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const messageId = '00000000-0000-4000-8000-000000000302'
const batch = [
{
header: {
id: conversationId,
title: '增量会话',
updatedAt: 1
},
messages: [
{
id: messageId,
role: 'assistant' as const,
content: '增量内容',
createdAt: 1,
state: 'streaming' as const,
tools: [
{
callId: 'remote-tool-call',
name: '远程工具',
state: 'completed' as const,
summary: '完整远程工具结果',
output: '远程输出'.repeat(10_000)
}
]
}
]
}
]
expect(
electronMocks.handlers.get(
ipcChannels.conversationsSaveLocal
)?.(event, batch)
).toBeUndefined()
expect(
assistantDatabase.saveLocalConversations
).toHaveBeenCalledWith(batch)
const branchInput = {
sourceConversationId: conversationId,
title: '增量会话 · 分支'
}
expect(
electronMocks.handlers.get(
ipcChannels.conversationsBranchLocal
)?.(event, branchInput)
).toMatchObject({
branch: { sourceConversationId: conversationId },
title: branchInput.title
})
expect(
assistantDatabase.branchLocalConversation
).toHaveBeenCalledWith(branchInput)
expect(
electronMocks.handlers.get(
ipcChannels.conversationsDeleteLocal
)?.(event, conversationId)
).toBe(true)
expect(
assistantDatabase.deleteLocalConversation
).toHaveBeenCalledWith(conversationId)
expect(contextManager.remove).toHaveBeenCalledWith(
queuedAttachmentId
)
expect(() =>
electronMocks.handlers.get(
ipcChannels.conversationsSaveLocal
)?.(event, [
{
...batch[0],
header: {
...batch[0]!.header,
remote: {
channel: 'weixin',
accountDisplay: 'remote',
conversationType: 'direct'
}
}
}
])
).toThrow()
expect(() =>
electronMocks.handlers.get(
ipcChannels.conversationsBranchLocal
)?.(event, {
sourceConversationId: 'not-a-uuid',
title: '无效分支'
})
).toThrow()
expect(() =>
electronMocks.handlers.get(
ipcChannels.conversationsDeleteLocal
)?.(event, 'not-a-uuid')
).toThrow()
await dispose()
})
it('waits for the renderer persistence acknowledgement before removing handlers', async () => {
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
}
const webContents = {
mainFrame: {
url: 'file:///goodbuddy/index.html'
},
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {})
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
electronMocks.handlers.get(
ipcChannels.appRendererPersistenceReady
)?.(event)
const disposal = dispose()
await vi.waitFor(() =>
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.appRendererPersistenceRequest,
expect.any(String)
)
)
expect(
electronMocks.handlers.has(ipcChannels.conversationsSaveLocal)
).toBe(true)
const requestId = webContents.send.mock.calls.find(
([channel]) =>
channel === ipcChannels.appRendererPersistenceRequest
)?.[1]
expect(requestId).toEqual(expect.any(String))
electronMocks.handlers.get(
ipcChannels.appRendererPersistenceComplete
)?.(event, requestId)
await disposal
expect(
electronMocks.handlers.has(ipcChannels.conversationsSaveLocal)
).toBe(false)
})
})
describe('registerIpcHandlers Runtime customization', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('validates customization, native inventory, and trusted manual compaction', async () => {
const projectId = '00000000-0000-4000-8000-000000000601'
const conversationId =
'00000000-0000-4000-8000-000000000602'
const requestId = '00000000-0000-4000-8000-000000000603'
const messageIds = [
'00000000-0000-4000-8000-000000000604',
'00000000-0000-4000-8000-000000000605'
]
const customization = {
opencode: { defaultAgent: 'planner' },
continue: { presets: [] }
}
const snapshot = {
provider: 'opencode' as const,
available: true,
inventoryStatus: 'available' as const,
detail: 'OpenCode native capabilities are ready',
agents: [
{
id: 'planner',
name: 'Planner',
mode: 'primary' as const,
native: true,
hidden: false
}
],
tools: [
{
id: 'edit',
name: 'edit',
kind: 'write' as const,
source: 'runtime' as const,
ask: 'blocked' as const,
execute: 'allowed' as const
}
],
toolsSupported: true,
commands: [],
lsp: [],
formatters: [],
mcpServers: [],
skills: [],
rules: [],
prompts: [],
resources: [],
resourcesSupported: true,
context: {
strategy: 'native' as const,
manualCompact: true,
detail: 'OpenCode manages native context'
}
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => true),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const settingsStore = {
getRuntimeCustomization: vi.fn(async () => customization),
updateRuntimeCustomization: vi.fn(
async (value: unknown) => value
),
getResolvedSettings: vi.fn(async () => ({
provider: 'opencode',
modelProfiles: [],
opencodeBaseUrl: '',
workspacePath: 'C:\\DefaultWorkspace'
}))
}
const messages = [
{
id: messageIds[0]!,
role: 'user' as const,
content: 'First turn',
state: 'complete' as const
},
{
id: messageIds[1]!,
role: 'assistant' as const,
content: 'Second turn',
state: 'complete' as const
}
]
let persistedRuntimeSelection:
| { provider: 'opencode' }
| undefined = { provider: 'opencode' }
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getProject: vi.fn(() => ({
id: projectId,
rootPath: 'C:\\ProjectWorkspace'
})),
getConversation: vi.fn(() => ({
id: conversationId,
projectId,
runtimeSelection: persistedRuntimeSelection,
title: 'Runtime conversation',
updatedAt: Date.now(),
messages
})),
createTask: vi.fn(),
updateTaskStatus: vi.fn(),
upsertModelUsageCall: vi.fn()
}
let releaseFirstCompaction: (() => void) | undefined
const firstCompactionBlocked = new Promise<void>((resolve) => {
releaseFirstCompaction = resolve
})
const compactionOutcome = {
result: {
provider: 'opencode' as const,
strategy: 'native' as const,
compacted: true,
detail: 'OpenCode compacted the conversation'
}
}
const selectedRuntimes = {
getNativeSnapshot: vi.fn(async () => snapshot),
compactConversation: vi
.fn()
.mockImplementationOnce(async () => {
await firstCompactionBlocked
return compactionOutcome
})
.mockResolvedValue(compactionOutcome)
}
const approvalBroker = { clear: vi.fn() }
const onRuntimeSettingsChanged = vi.fn(async () => undefined)
const contextManager = { clear: vi.fn() }
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
settingsStore as never,
{} as never,
contextManager as never,
{} as never,
assistantDatabase as never,
approvalBroker as never,
{} as never,
onRuntimeSettingsChanged,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
selectedRuntimes as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeCustomizationGet
)?.(event)
).resolves.toEqual(customization)
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeCustomizationUpdate
)?.(event, customization)
).resolves.toEqual(customization)
expect(
settingsStore.updateRuntimeCustomization
).toHaveBeenCalledWith(customization)
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
expect(approvalBroker.clear).toHaveBeenCalledOnce()
const activationError = new Error('customization activation failed')
onRuntimeSettingsChanged
.mockRejectedValueOnce(activationError)
.mockResolvedValueOnce(undefined)
const failedCustomization = {
...customization,
opencode: { defaultAgent: 'candidate-agent' }
}
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeCustomizationUpdate
)?.(event, failedCustomization)
).rejects.toBe(activationError)
expect(
settingsStore.updateRuntimeCustomization
).toHaveBeenNthCalledWith(2, failedCustomization)
expect(
settingsStore.updateRuntimeCustomization
).toHaveBeenNthCalledWith(3, customization)
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(3)
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeCustomizationUpdate
)?.(event, {
...customization,
unknown: true
})
).rejects.toThrow()
await expect(
electronMocks.handlers.get(
ipcChannels.runtimeNativeSnapshot
)?.(event, {
provider: 'opencode',
projectId
})
).resolves.toEqual(snapshot)
expect(selectedRuntimes.getNativeSnapshot).toHaveBeenCalledWith(
{ provider: 'opencode' },
expect.objectContaining({
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
})
)
const compactInput = {
requestId,
conversationId,
projectId,
runtimeSelection: { provider: 'opencode' as const },
history: messages.map(({ role, content }) => ({
role,
content
})),
historyMessageIds: messageIds
}
const firstCompaction = electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, compactInput)
await vi.waitFor(() =>
expect(selectedRuntimes.compactConversation).toHaveBeenCalledOnce()
)
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, compactInput)
).rejects.toThrow('上下文压缩请求正在执行')
releaseFirstCompaction?.()
await expect(firstCompaction).resolves.toEqual({
provider: 'opencode',
strategy: 'native',
compacted: true,
detail: 'OpenCode compacted the conversation'
})
expect(selectedRuntimes.compactConversation).toHaveBeenCalledWith(
expect.objectContaining(compactInput),
expect.objectContaining({
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
}),
expect.any(AbortSignal)
)
expect(assistantDatabase.createTask).toHaveBeenCalledWith(
expect.objectContaining({
id: requestId,
visible: false
})
)
expect(assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000606',
runtimeSelection: { provider: 'continue' }
})
).rejects.toThrow('对话 Runtime 或 Project 已更改')
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000607',
projectId: '00000000-0000-4000-8000-000000000608'
})
).rejects.toThrow('对话 Runtime 或 Project 已更改')
persistedRuntimeSelection = undefined
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000609'
})
).resolves.toMatchObject({
provider: 'opencode',
compacted: true
})
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000610',
runtimeSelection: { provider: 'continue' }
})
).rejects.toThrow('对话 Runtime 或 Project 已更改')
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(event, {
...compactInput,
requestId: '00000000-0000-4000-8000-000000000611',
history: [
compactInput.history[0],
{ role: 'assistant', content: 'stale content' }
]
})
).rejects.toThrow('对话历史已更改')
expect(selectedRuntimes.compactConversation).toHaveBeenCalledTimes(2)
await dispose()
})
})
describe('registerIpcHandlers agent terminal state', () => {
type RemoteTaskEventMockInput = {
taskId: string
bindingId: string
operationId: string
semanticSequence: string
eventIndex: number
kind: string
payload: unknown
}
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
function createHarness(
runtime: Record<string, unknown>,
onBeforeClearLocalData?: () => Promise<void>,
toolApproval: 'always' | 'policy' = 'always',
subagentService?: Record<string, unknown>,
smartRoutingEnabled = false,
selectedRuntimes?: Record<string, unknown>,
knowledgeServiceOverride?: Record<string, unknown>,
knowledgeGateway?: Record<string, unknown>,
magicNotesEnabled = false,
goodbuddyConfigService?: Record<string, unknown>,
capabilityServiceOverride?: Record<string, unknown>
) {
const assistantDatabase = {
createTask: vi.fn(),
appendTaskEvent: vi.fn(),
appendRemoteTaskEventOnce: vi.fn<
(input: RemoteTaskEventMockInput) => boolean
>(() => true),
appendRemoteConversationTaskEventOnce: vi.fn(() => true),
listRecoverableRemoteTasks: vi.fn<
() => Array<{
taskId: string
projectId: string
conversationId: string
currentUserMessageId: string
currentAssistantMessageId: string
instructions: string
workMode: 'ask' | 'execute'
status: 'running' | 'waiting_approval' | 'interrupted'
}>
>(() => []),
getHighestCommittedRemoteTaskEventSequenceForTask: vi.fn(
() => '0'
),
failRecoverableRemoteTask: vi.fn(),
updateTaskStatus: vi.fn(),
createTextArtifact: vi.fn(),
createImageArtifact: vi.fn(() => ({
id: '00000000-0000-4000-8000-000000000499',
title: '生成图片'
})),
listProjects: vi.fn<() => AssistantProject[]>(() => [
{
id: '00000000-0000-4000-8000-000000000401',
name: '企业微信',
description: '企业微信远程消息与受控任务',
rootPath: 'C:\\ProjectWorkspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
},
defaultWorkMode: 'ask',
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000001'
},
kind: 'channel',
channel: 'wecom',
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
]),
getOrCreateRemoteConversation: vi.fn(() => ({
id: '00000000-0000-4000-8000-000000000402',
projectId: '00000000-0000-4000-8000-000000000401',
title: '企业微信 · ****er-1',
updatedAt: Date.now(),
messages: []
})),
appendConversationMessage: vi.fn(),
appendRemoteConversationMessage: vi.fn(),
listConversationQueueItems: vi.fn<
() => ConversationQueueItem[]
>(() => []),
listPendingScheduleQueueConversationIds: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getConversationQueueItem: vi.fn(),
getConversationUserQueuePayloadJson: vi.fn(),
isConversationUserQueueItemDispatching: vi.fn(() => true),
enqueueConversationUserInput: vi.fn(),
claimConversationQueueItem: vi.fn(),
completeConversationUserQueueItem: vi.fn(),
releaseConversationUserQueueItem: vi.fn(),
cancelConversationQueueItem: vi.fn(),
queueDueSchedules: vi.fn(() => []),
queueScheduleNow: vi.fn(),
completeScheduleRun: vi.fn(),
branchLocalConversation: vi.fn(() => ({
id: '00000000-0000-4000-8000-000000000403',
branch: {
sourceConversationId:
'00000000-0000-4000-8000-000000000402',
sourceTitle: '来源会话'
},
title: '来源会话 · 分支',
updatedAt: Date.now(),
messages: []
})),
listSchedules: vi.fn(() => []),
createSchedule: vi.fn(),
setScheduleEnabled: vi.fn(),
removeSchedule: vi.fn(),
upsertModelUsageCall: vi.fn(),
clearAssistantData: vi.fn(),
listExperts: vi.fn<() => Array<Record<string, unknown>>>(() => []),
getExpert: vi.fn(),
getConversation: vi.fn<
(conversationId: string) => Record<string, unknown>
>((conversationId) => ({
id: conversationId,
runtimeSelection: { provider: 'opencode' },
messages: []
})),
getProject: vi.fn<
(projectId: string) => Record<string, unknown>
>((projectId) => ({
id: projectId,
rootPath: 'C:\\ProjectWorkspace'
}))
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => true),
on: vi.fn(),
removeListener: vi.fn()
}
const contextManager = {
enrichRequest: vi.fn((request) => request),
ingestRemoteAttachment: vi.fn(async (attachment: {
name: string
size: number
kind: 'image' | 'file'
}) => ({
id: '00000000-0000-4000-8000-000000000498',
name: attachment.name,
size: attachment.size,
preview: '远程附件',
kind: attachment.kind === 'image' ? 'image' : 'text'
})),
remove: vi.fn(),
serializeForQueue: vi.fn(() => '[]'),
restoreFromQueue: vi.fn(),
clear: vi.fn()
}
const approvalBroker = {
request: vi.fn(),
respond: vi.fn(),
clear: vi.fn()
}
const getResolvedSettings = vi.fn(
async (): Promise<Record<string, unknown>> => ({
toolApproval,
subagentSmartRoutingEnabled: smartRoutingEnabled
})
)
const getPolicySettings = vi.fn(
async (): Promise<Record<string, unknown>> => ({
toolApproval,
subagentSmartRoutingEnabled: smartRoutingEnabled
})
)
const getApplicationSettings = vi.fn(
async (): Promise<Record<string, unknown>> => ({
magicNotesEnabled,
remoteProjectsEnabled: true
})
)
const onRuntimeSettingsChanged = vi.fn(async () => {})
const dispose = registerIpcHandlers(
window as never,
runtime as never,
'CommandOrControl+Shift+Space',
{
getPolicySettings,
getResolvedSettings,
getRuntimeCustomization: vi.fn(async () => ({
opencode: {},
continue: { presets: [] }
})),
updateRuntimeCustomization: vi.fn(
async (settings: unknown) => settings
)
} as never,
(capabilityServiceOverride ?? {}) as never,
contextManager as never,
(knowledgeServiceOverride ?? {
database: { listKnowledgeBases: vi.fn(() => []) }
}) as never,
assistantDatabase as never,
approvalBroker as never,
{} as never,
onRuntimeSettingsChanged,
onBeforeClearLocalData,
undefined,
subagentService as never,
undefined,
{
get: getApplicationSettings
} as never,
undefined,
undefined,
undefined,
selectedRuntimes as never,
undefined,
knowledgeGateway as never,
undefined,
undefined,
undefined,
undefined,
undefined,
goodbuddyConfigService as never
)
return {
approvalBroker,
assistantDatabase,
contextManager,
dispose,
getApplicationSettings,
getPolicySettings,
getResolvedSettings,
onRuntimeSettingsChanged,
clearHandler: electronMocks.handlers.get(
ipcChannels.appClearLocalData
),
handler: electronMocks.handlers.get(ipcChannels.agentRun),
statusHandler: electronMocks.handlers.get(ipcChannels.agentStatus),
cancelHandler: electronMocks.handlers.get(ipcChannels.agentCancel),
recoveryGetHandler: electronMocks.handlers.get(
ipcChannels.remoteProjectRecoveryGet
),
recoveryRetryHandler: electronMocks.handlers.get(
ipcChannels.remoteProjectRecoveryRetry
),
branchHandler: electronMocks.handlers.get(
ipcChannels.conversationsBranchLocal
),
knowledgeSearchHandler: electronMocks.handlers.get(
ipcChannels.knowledgeSearch
),
knowledgeRebuildHandler: electronMocks.handlers.get(
ipcChannels.knowledgeRebuildLibrary
),
knowledgeCancelRebuildHandler: electronMocks.handlers.get(
ipcChannels.knowledgeCancelRebuild
),
webContents
}
}
const trustedEvent = (webContents: {
mainFrame: { url: string }
}) => ({
sender: webContents,
senderFrame: webContents.mainFrame
})
function createManagedSshHarness(
selectedRuntime: Record<string, unknown>
) {
const projectId = '00000000-0000-4000-8000-000000000761'
const selectedRuntimes = {
getRuntime: vi.fn(async () => selectedRuntime)
}
const harness = createHarness(
{
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
run: vi.fn()
},
undefined,
'always',
undefined,
false,
selectedRuntimes
)
harness.assistantDatabase.getProject.mockReturnValue({
id: projectId,
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000762',
remoteRootPath: '/srv/project'
}
})
return { harness, projectId, selectedRuntimes }
}
const managedSshRequest = (
projectId: string,
requestId: string,
conversationId: string
) => ({
requestId,
conversationId,
projectId,
runtimeSelection: { provider: 'opencode' as const },
prompt: 'continue on Agent',
workMode: 'execute' as const,
knowledgeLibraryIds: []
})
it('defaults custom scheduled Tasks to Execute', async () => {
const harness = createHarness({
runtimeId: 'continue',
capability: 'chat',
supportsToolExecution: true,
async *run() {
yield { type: 'done', requestId: 'unused' } as const
}
})
const conversationId =
'00000000-0000-4000-8000-000000000705'
const createInput = {
projectId: '00000000-0000-4000-8000-000000000401',
conversationId,
title: '每周汇总',
prompt: '汇总本周进展',
recurrence: 'weekly' as const,
nextRunAt: '2026-08-21T09:00:00.000Z'
}
await electronMocks.handlers.get(
ipcChannels.schedulesCreate
)?.(trustedEvent(harness.webContents), createInput)
expect(
harness.assistantDatabase.createSchedule
).toHaveBeenCalledWith({
...createInput,
workMode: 'execute'
})
await harness.dispose()
})
it('reuses a scheduled Task and writes text results to its Conversation', async () => {
const taskId = '00000000-0000-4000-8000-000000000701'
const conversationId =
'00000000-0000-4000-8000-000000000702'
const scheduleId =
'00000000-0000-4000-8000-000000000703'
const runId = '00000000-0000-4000-8000-000000000704'
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(
request: { requestId: string },
_signal: AbortSignal,
authorize: (
input: {
scopeKey: string
title: string
description: string
}
) => Promise<string>
) {
expect(request.requestId).toBe(runId)
await authorize({
scopeKey: 'workspace.write',
title: '写入工作区',
description: '更新状态文件'
})
yield {
requestId: request.requestId,
type: 'text',
delta: '每日状态正常'
} as const
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
const schedule = {
id: scheduleId,
projectId: '00000000-0000-4000-8000-000000000401',
taskId,
conversationId,
title: '每日状态',
prompt: '汇总状态',
workMode: 'execute' as const,
recurrence: 'daily' as const,
nextRunAt: '2026-08-20T00:00:00.000Z',
enabled: true,
createdAt: '2026-08-19T00:00:00.000Z',
updatedAt: '2026-08-19T00:00:00.000Z'
}
const queueItem = {
id: runId,
conversationId,
source: 'schedule' as const,
label: schedule.title,
scheduleRunId: runId,
scheduleId,
taskId,
createdAt: '2026-08-19T00:01:00.000Z'
}
harness.assistantDatabase.queueScheduleNow.mockReturnValue(
queueItem
)
harness.assistantDatabase.listConversationQueueItems
.mockReturnValueOnce([queueItem])
.mockReturnValue([])
harness.assistantDatabase.claimConversationQueueItem.mockReturnValue({
source: 'schedule',
item: queueItem,
schedule,
runId
})
harness.approvalBroker.request.mockResolvedValue('once')
await electronMocks.handlers.get(
ipcChannels.schedulesRunNow
)?.(trustedEvent(harness.webContents), scheduleId)
await vi.waitFor(() =>
expect(
harness.assistantDatabase.completeScheduleRun
).toHaveBeenCalledWith(runId, 'completed')
)
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(taskId, 'running')
expect(harness.approvalBroker.request).toHaveBeenCalledWith(
expect.objectContaining({
requestId: taskId,
conversationId
}),
expect.any(AbortSignal),
expect.any(Function)
)
expect(
harness.assistantDatabase.appendConversationMessage
).toHaveBeenCalledWith({
conversationId,
role: 'assistant',
content: '每日状态正常',
status: '定时任务',
task: {
id: taskId,
title: '每日状态'
}
})
expect(
harness.assistantDatabase.createTextArtifact
).not.toHaveBeenCalled()
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationsChanged
)
await harness.dispose()
})
it('fails scheduled runs promptly when a Runtime asks an interactive question', async () => {
const taskId = '00000000-0000-4000-8000-000000000711'
const conversationId =
'00000000-0000-4000-8000-000000000712'
const scheduleId =
'00000000-0000-4000-8000-000000000713'
const runId = '00000000-0000-4000-8000-000000000714'
const respondToQuestion = vi.fn().mockResolvedValue(undefined)
const runtime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
respondToQuestion,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'question',
questionId: 'opencode-background-question',
questions: [
{
header: 'Choice',
question: 'Choose an implementation',
options: [],
multiple: false,
custom: true
}
]
} as const
await new Promise(() => undefined)
}
}
const harness = createHarness(runtime)
const schedule = {
id: scheduleId,
projectId: '00000000-0000-4000-8000-000000000401',
taskId,
conversationId,
title: '后台提问测试',
prompt: '执行任务',
workMode: 'execute' as const,
recurrence: 'daily' as const,
nextRunAt: '2026-08-20T00:00:00.000Z',
enabled: true,
createdAt: '2026-08-19T00:00:00.000Z',
updatedAt: '2026-08-19T00:00:00.000Z'
}
const queueItem = {
id: runId,
conversationId,
source: 'schedule' as const,
label: schedule.title,
scheduleRunId: runId,
scheduleId,
taskId,
createdAt: '2026-08-19T00:01:00.000Z'
}
harness.assistantDatabase.queueScheduleNow.mockReturnValue(
queueItem
)
harness.assistantDatabase.listConversationQueueItems
.mockReturnValueOnce([queueItem])
.mockReturnValue([])
harness.assistantDatabase.claimConversationQueueItem.mockReturnValue({
source: 'schedule',
item: queueItem,
schedule,
runId
})
await electronMocks.handlers.get(
ipcChannels.schedulesRunNow
)?.(trustedEvent(harness.webContents), scheduleId)
await vi.waitFor(() =>
expect(
harness.assistantDatabase.completeScheduleRun
).toHaveBeenCalledWith(runId, 'failed')
)
expect(respondToQuestion).toHaveBeenCalledWith(
'opencode-background-question'
)
expect(
harness.assistantDatabase.appendConversationMessage
).toHaveBeenCalledWith(
expect.objectContaining({
conversationId,
state: 'error',
status: '定时任务失败',
content:
'后台任务无法回答 Runtime 交互提问。请改为在 GoodBuddy 对话中运行,或调整提示词和工具配置以避免交互提问。'
})
)
await harness.dispose()
})
it('serializes Agent runs that target the same Conversation', async () => {
let finishRun: (() => void) | undefined
const runtimeStarted = vi.fn()
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
async *run(request: { requestId: string }) {
runtimeStarted(request.requestId)
await new Promise<void>((resolve) => {
finishRun = resolve
})
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
const conversationId =
'00000000-0000-4000-8000-000000000721'
const firstRun = harness.handler?.(
trustedEvent(harness.webContents),
{
requestId: '00000000-0000-4000-8000-000000000722',
conversationId,
prompt: '第一条',
workMode: 'ask',
knowledgeLibraryIds: []
}
)
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000723',
conversationId,
prompt: '第二条',
workMode: 'ask',
knowledgeLibraryIds: []
})
).rejects.toThrow('当前对话已有执行中的请求')
finishRun?.()
await firstRun
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000722',
'completed'
)
)
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000724',
conversationId,
prompt: '第三条',
workMode: 'ask',
knowledgeLibraryIds: []
})
).resolves.toBeUndefined()
await vi.waitFor(() =>
expect(runtimeStarted).toHaveBeenCalledTimes(2)
)
finishRun?.()
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000724',
'completed'
)
)
await harness.dispose()
})
it('keeps a replacement lease owned after the bulk-aborted lease releases', async () => {
const controls = new Map<
string,
{
finish: () => void
signal: AbortSignal
}
>()
const runtimeStarted = vi.fn()
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
async *run(
request: { conversationId: string; requestId: string },
signal: AbortSignal
) {
runtimeStarted(request.conversationId)
await new Promise<void>((resolve) => {
controls.set(request.conversationId, {
finish: resolve,
signal
})
})
signal.throwIfAborted()
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const goodbuddyConfigService = {
takePendingReload: vi.fn(() => 'none'),
revokeRequest: vi.fn(),
clear: vi.fn()
}
const knowledgeGateway = {
grant: vi.fn(() => 'lease-race-capability'),
getAvailableToolNames: vi.fn(() => []),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
goodbuddyConfigService
)
const requestId = '00000000-0000-4000-8000-000000000741'
const oldConversationId =
'00000000-0000-4000-8000-000000000742'
const newConversationId =
'00000000-0000-4000-8000-000000000743'
const event = trustedEvent(harness.webContents)
await harness.handler?.(event, {
requestId,
conversationId: oldConversationId,
prompt: '旧请求',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(controls.has(oldConversationId)).toBe(true)
)
await electronMocks.handlers.get(
ipcChannels.runtimeCustomizationUpdate
)?.(event, {
opencode: {},
continue: { presets: [] }
})
expect(controls.get(oldConversationId)?.signal.aborted).toBe(true)
await harness.handler?.(event, {
requestId,
conversationId: newConversationId,
prompt: '替换请求',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(controls.has(newConversationId)).toBe(true)
)
controls.get(oldConversationId)?.finish()
await vi.waitFor(() =>
expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce()
)
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
harness.cancelHandler?.(event, requestId)
expect(controls.get(newConversationId)?.signal.aborted).toBe(true)
await expect(
harness.handler?.(event, {
requestId: '00000000-0000-4000-8000-000000000744',
conversationId: newConversationId,
prompt: '不能并发',
workMode: 'ask',
knowledgeLibraryIds: []
})
).rejects.toThrow('当前对话已有执行中的请求')
controls.get(newConversationId)?.finish()
await vi.waitFor(() => {
const cancellations =
harness.assistantDatabase.updateTaskStatus.mock.calls.filter(
([id, status]) =>
id === requestId && status === 'cancelled'
)
expect(cancellations).toHaveLength(2)
})
await vi.waitFor(() =>
expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledTimes(2)
)
await harness.handler?.(event, {
requestId,
conversationId: newConversationId,
prompt: '清理后重试',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(runtimeStarted).toHaveBeenCalledTimes(3)
)
controls.get(newConversationId)?.finish()
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
await harness.dispose()
})
it('detaches an accepted managed SSH run without aborting it or waiting for completion', async () => {
let finishRun = (): void => undefined
let remoteSignal: AbortSignal | undefined
let statusConsumed = false
const remoteRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(
request: { requestId: string },
signal: AbortSignal
) {
remoteSignal = signal
yield {
requestId: request.requestId,
type: 'status',
message: '远端 ACP Runtime 正在处理请求'
} as const
statusConsumed = true
await new Promise<void>((resolve) => {
finishRun = resolve
})
signal.throwIfAborted()
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const selectedRuntimes = {
getRuntime: vi.fn(async () => remoteRuntime)
}
const harness = createHarness(
remoteRuntime,
undefined,
'always',
undefined,
false,
selectedRuntimes
)
const projectId = '00000000-0000-4000-8000-000000000751'
harness.assistantDatabase.getProject.mockReturnValue({
id: projectId,
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000752',
remoteRootPath: '/srv/project'
}
})
const requestId = '00000000-0000-4000-8000-000000000753'
const run = harness.handler?.(
trustedEvent(harness.webContents),
{
requestId,
conversationId: 'managed-ssh-conversation',
projectId,
runtimeSelection: { provider: 'opencode' },
prompt: 'continue on Agent',
workMode: 'execute',
knowledgeLibraryIds: []
}
)
await run
await vi.waitFor(() => expect(statusConsumed).toBe(true))
const disposal = harness.dispose()
await expect(disposal).resolves.toBeUndefined()
expect(remoteSignal?.aborted).toBe(false)
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
finishRun()
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
})
it('durably commits remote text before resume, deduplicates it, and preserves its semantic payload', async () => {
const requestId = '00000000-0000-4000-8000-000000000763'
const provenance = {
source: 'remote-semantic-transcript' as const,
bindingId: 'binding-semantic-text',
operationId: 'operation-semantic-text',
semanticSequence: '1',
eventIndex: 0
}
const taggedDelta = '<think>remote reasoning</think>remote answer'
const resumed = vi.fn()
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
const remoteText = {
requestId: request.requestId,
type: 'text',
delta: taggedDelta,
remoteProvenance: provenance
} as const
yield remoteText
expect(
harness.assistantDatabase.appendRemoteTaskEventOnce
).toHaveBeenCalledWith({
taskId: request.requestId,
bindingId: provenance.bindingId,
operationId: provenance.operationId,
semanticSequence: provenance.semanticSequence,
eventIndex: provenance.eventIndex,
kind: 'text',
payload: {
requestId: request.requestId,
type: 'text',
delta: taggedDelta
}
})
resumed('first')
yield remoteText
resumed('duplicate')
yield {
requestId: request.requestId,
type: 'done',
remoteProvenance: {
...provenance,
semanticSequence: '2'
}
} as const
}
}
const fixture = createManagedSshHarness(selectedRuntime)
const { harness } = fixture
harness.assistantDatabase.appendRemoteTaskEventOnce
.mockReturnValueOnce(true)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
await harness.handler?.(
trustedEvent(harness.webContents),
managedSshRequest(
fixture.projectId,
requestId,
'managed-ssh-semantic-text'
)
)
await vi.waitFor(() =>
expect(
harness?.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(resumed.mock.calls).toEqual([['first'], ['duplicate']])
const persistedTextCalls =
harness.assistantDatabase.appendRemoteTaskEventOnce.mock.calls.filter(
([input]) => input.kind === 'text'
)
expect(persistedTextCalls).toHaveLength(2)
const publicEvents = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent)
.map(([, event]) => event)
.filter((event) => event.requestId === requestId)
expect(publicEvents).toEqual([
{
requestId,
type: 'text',
delta: taggedDelta
},
{
requestId,
type: 'done'
}
])
expect(
publicEvents.some((event) => event.type === 'reasoning')
).toBe(false)
expect(
publicEvents.some((event) => 'remoteProvenance' in event)
).toBe(false)
await harness.dispose()
})
it('persists a remote semantic checkpoint without publishing it', async () => {
const requestId = '00000000-0000-4000-8000-000000000764'
const provenance = {
source: 'remote-semantic-transcript' as const,
bindingId: 'binding-semantic-checkpoint',
operationId: 'operation-semantic-checkpoint',
semanticSequence: '1',
eventIndex: 0
}
const resumedAfterCheckpoint = vi.fn()
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'remote-semantic-checkpoint',
remoteProvenance: provenance
} as const
expect(
harness.assistantDatabase.appendRemoteTaskEventOnce
).toHaveBeenCalledWith({
taskId: request.requestId,
bindingId: provenance.bindingId,
operationId: provenance.operationId,
semanticSequence: provenance.semanticSequence,
eventIndex: provenance.eventIndex,
kind: 'remote-semantic-checkpoint',
payload: {
requestId: request.requestId,
type: 'remote-semantic-checkpoint'
}
})
resumedAfterCheckpoint()
yield {
requestId: request.requestId,
type: 'done',
remoteProvenance: {
...provenance,
semanticSequence: '2'
}
} as const
}
}
const fixture = createManagedSshHarness(selectedRuntime)
const { harness } = fixture
await harness.handler?.(
trustedEvent(harness.webContents),
managedSshRequest(
fixture.projectId,
requestId,
'managed-ssh-semantic-checkpoint'
)
)
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(resumedAfterCheckpoint).toHaveBeenCalledOnce()
expect(harness.webContents.send).not.toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({
requestId,
type: 'remote-semantic-checkpoint'
})
)
await harness.dispose()
})
it('persists remote done before resuming the generator once for Agent acknowledgement', async () => {
const requestId = '00000000-0000-4000-8000-000000000765'
const lifecycle: string[] = []
const provenance = {
source: 'remote-semantic-transcript' as const,
bindingId: 'binding-semantic-done',
operationId: 'operation-semantic-done',
semanticSequence: '1',
eventIndex: 0
}
const resumedAfterDone = vi.fn(() => {
lifecycle.push('resume-done')
})
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'done',
remoteProvenance: provenance
} as const
resumedAfterDone()
}
}
const { harness, projectId } =
createManagedSshHarness(selectedRuntime)
harness.assistantDatabase.appendRemoteTaskEventOnce.mockImplementation(
(input) => {
lifecycle.push(`persist-${input.kind}`)
return true
}
)
await harness.handler?.(
trustedEvent(harness.webContents),
managedSshRequest(
projectId,
requestId,
'managed-ssh-semantic-done'
)
)
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(lifecycle).toEqual(['persist-done', 'resume-done'])
expect(resumedAfterDone).toHaveBeenCalledOnce()
expect(
harness.assistantDatabase.appendTaskEvent
).not.toHaveBeenCalledWith(
requestId,
'done',
expect.anything()
)
await harness.dispose()
})
it('persists and acknowledges a remote terminal error before failing without duplicate delivery', async () => {
const requestId = '00000000-0000-4000-8000-000000000766'
const errorMessage = 'remote Agent failed'
const lifecycle: string[] = []
const provenance = {
source: 'remote-semantic-transcript' as const,
bindingId: 'binding-semantic-error',
operationId: 'operation-semantic-error',
semanticSequence: '1',
eventIndex: 0
}
const resumedAfterError = vi.fn(() => {
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(
requestId,
'failed',
expect.any(String)
)
lifecycle.push('resume-error')
})
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'error',
status: 'failed',
message: errorMessage,
remoteProvenance: provenance
} as const
resumedAfterError()
}
}
const fixture = createManagedSshHarness(selectedRuntime)
const { harness } = fixture
harness.assistantDatabase.appendRemoteTaskEventOnce.mockImplementation(
(input) => {
lifecycle.push(`persist-${input.kind}`)
return true
}
)
await harness.handler?.(
trustedEvent(harness.webContents),
managedSshRequest(
fixture.projectId,
requestId,
'managed-ssh-semantic-error'
)
)
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(
requestId,
'failed',
expect.any(String)
)
)
expect(lifecycle).toEqual(['persist-error', 'resume-error'])
expect(resumedAfterError).toHaveBeenCalledOnce()
expect(
harness.assistantDatabase.appendTaskEvent
).not.toHaveBeenCalledWith(
requestId,
'error',
expect.anything()
)
const publicErrors = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent)
.map(([, event]) => event)
.filter(
(event) =>
event.requestId === requestId && event.type === 'error'
)
expect(publicErrors).toEqual([
{
requestId,
type: 'error',
status: 'failed',
message: errorMessage
}
])
await harness.dispose()
})
it('reattaches a recoverable task from its committed cursor and completes project synchronization', async () => {
const projectId = '00000000-0000-4000-8000-000000000781'
const taskId = '00000000-0000-4000-8000-000000000782'
const conversationId =
'00000000-0000-4000-8000-000000000783'
const userMessageId =
'00000000-0000-4000-8000-000000000784'
const assistantMessageId =
'00000000-0000-4000-8000-000000000785'
const consumed: string[] = []
const recoveredRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(request: {
requestId: string
remoteRecoveryOnly?: boolean
remoteSemanticAfterSequence?: string
remoteRecoveredTools?: unknown[]
}) {
expect(request).toMatchObject({
requestId: taskId,
remoteRecoveryOnly: true,
remoteSemanticAfterSequence: '2',
remoteRecoveredTools: [
expect.objectContaining({
callId: 'tool-before-restart',
name: 'Read',
state: 'failed'
})
]
})
yield {
requestId: taskId,
type: 'text',
delta: 'recovered answer',
remoteProvenance: {
source: 'remote-semantic-transcript',
bindingId: 'binding-recovery',
operationId: taskId,
semanticSequence: '3',
eventIndex: 0
}
} as const
consumed.push('3')
yield {
requestId: taskId,
type: 'remote-semantic-checkpoint',
remoteProvenance: {
source: 'remote-semantic-transcript',
bindingId: 'binding-recovery',
operationId: taskId,
semanticSequence: '3',
eventIndex: 1
}
} as const
yield {
requestId: taskId,
type: 'done',
sessionId: 'session-recovery',
remoteProvenance: {
source: 'remote-semantic-transcript',
bindingId: 'binding-recovery',
operationId: taskId,
semanticSequence: '4',
eventIndex: 0
}
} as const
consumed.push('4')
yield {
requestId: taskId,
type: 'remote-semantic-checkpoint',
remoteProvenance: {
source: 'remote-semantic-transcript',
bindingId: 'binding-recovery',
operationId: taskId,
semanticSequence: '4',
eventIndex: 1
}
} as const
}
}
const {
harness,
selectedRuntimes
} = createManagedSshHarness(recoveredRuntime)
harness.assistantDatabase.getProject.mockReturnValue({
id: projectId,
runtimeSelection: { provider: 'opencode' },
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000786',
remoteRootPath: '/srv/project'
}
})
harness.assistantDatabase.getConversation.mockReturnValue({
id: conversationId,
projectId,
runtimeSelection: { provider: 'opencode' },
messages: [
{
id: assistantMessageId,
role: 'assistant',
content: '',
state: 'streaming',
createdAt: 1,
tools: [
{
callId: 'tool-before-restart',
name: 'Read',
state: 'failed',
summary: 'Read failed',
error: 'missing file'
}
]
}
]
})
harness.assistantDatabase.listRecoverableRemoteTasks.mockReturnValue([
{
taskId,
projectId,
conversationId,
currentUserMessageId: userMessageId,
currentAssistantMessageId: assistantMessageId,
instructions: 'continue remotely',
workMode: 'execute',
status: 'running'
}
])
harness.assistantDatabase.getHighestCommittedRemoteTaskEventSequenceForTask.mockReturnValue(
'2'
)
harness.recoveryGetHandler?.(
trustedEvent(harness.webContents)
)
await vi.waitFor(() => expect(consumed).toEqual(['3', '4']))
await vi.waitFor(() =>
expect(
harness.assistantDatabase.appendRemoteConversationTaskEventOnce
).toHaveBeenCalledTimes(4)
)
expect(
harness.assistantDatabase.appendRemoteConversationTaskEventOnce
).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
taskId,
conversationId,
assistantMessageId,
semanticSequence: '3',
event: {
requestId: taskId,
type: 'text',
delta: 'recovered answer'
}
})
)
expect(
harness.assistantDatabase.appendRemoteConversationTaskEventOnce
).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
taskId,
semanticSequence: '4',
event: {
requestId: taskId,
type: 'error',
status: 'failed',
message: 'Read 工具执行失败:missing file'
}
})
)
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.remoteProjectRecoveryProgress,
expect.objectContaining({
projectId,
stage: 'completed'
})
)
)
// The renderer converges on the persisted terminal state by re-reading
// conversations, so the change notice must land before `completed`.
const sendCalls = harness.webContents.send.mock.calls
const completedIndex = sendCalls.findIndex(
([channel, payload]) =>
channel === ipcChannels.remoteProjectRecoveryProgress &&
(payload as { stage?: string }).stage === 'completed'
)
expect(completedIndex).toBeGreaterThan(0)
expect(
sendCalls
.slice(0, completedIndex)
.some(([channel]) => channel === ipcChannels.conversationsChanged)
).toBe(true)
await harness.dispose()
})
it('aborts and drains a local run during Desktop shutdown', async () => {
let localSignal: AbortSignal | undefined
let finishLocalCleanup = (): void => undefined
let markLocalCleanupStarted = (): void => undefined
const localCleanupStarted = new Promise<void>((resolve) => {
markLocalCleanupStarted = resolve
})
const localRuntime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
async *run(
request: { requestId: string },
signal: AbortSignal
) {
localSignal = signal
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => resolve(), {
once: true
})
})
await new Promise<void>((resolve) => {
finishLocalCleanup = resolve
markLocalCleanupStarted()
})
signal.throwIfAborted()
yield { requestId: request.requestId, type: 'done' } as const
}
}
const harness = createHarness(localRuntime)
const requestId = '00000000-0000-4000-8000-000000000754'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'local-shutdown-conversation',
prompt: 'local work',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(localSignal).toBeDefined())
const event = trustedEvent(harness.webContents)
electronMocks.handlers.get(
ipcChannels.appRendererPersistenceReady
)?.(event)
const disposal = harness.dispose()
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.appRendererPersistenceRequest,
expect.any(String)
)
)
expect(localSignal?.aborted).toBe(true)
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
const persistenceRequestId =
harness.webContents.send.mock.calls.find(
([channel]) =>
channel === ipcChannels.appRendererPersistenceRequest
)?.[1]
electronMocks.handlers.get(
ipcChannels.appRendererPersistenceComplete
)?.(event, persistenceRequestId)
await localCleanupStarted
finishLocalCleanup()
await disposal
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
})
it('keeps an accepted managed SSH run recoverable after user Stop', async () => {
let remoteSignal: AbortSignal | undefined
let statusConsumed = false
const remoteRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(
request: { requestId: string },
signal: AbortSignal
) {
remoteSignal = signal
yield {
requestId: request.requestId,
type: 'status',
message: '远端 ACP Runtime 正在处理请求'
} as const
statusConsumed = true
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => resolve(), {
once: true
})
})
signal.throwIfAborted()
yield { requestId: request.requestId, type: 'done' } as const
}
}
const selectedRuntimes = {
getRuntime: vi.fn(async () => remoteRuntime)
}
const harness = createHarness(
remoteRuntime,
undefined,
'always',
undefined,
false,
selectedRuntimes
)
const projectId = '00000000-0000-4000-8000-000000000755'
harness.assistantDatabase.getProject.mockReturnValue({
id: projectId,
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000756',
remoteRootPath: '/srv/project'
}
})
const requestId = '00000000-0000-4000-8000-000000000757'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'managed-ssh-stop-conversation',
projectId,
runtimeSelection: { provider: 'opencode' },
prompt: 'stop this',
workMode: 'execute',
knowledgeLibraryIds: [],
currentUserMessageId:
'00000000-0000-4000-8000-000000000758',
currentAssistantMessageId:
'00000000-0000-4000-8000-000000000759'
})
await vi.waitFor(() => expect(statusConsumed).toBe(true))
harness.cancelHandler?.(
trustedEvent(harness.webContents),
requestId
)
expect(remoteSignal?.aborted).toBe(true)
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'interrupted', '请求已取消')
)
await harness.dispose()
})
it('keeps a managed SSH start ambiguity recoverable before its first event', async () => {
const remoteRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(): AsyncGenerator<never, void, void> {
await Promise.reject(
new Error('prompt start response was lost')
)
yield undefined as never
}
}
const selectedRuntimes = {
getRuntime: vi.fn(async () => remoteRuntime)
}
const harness = createHarness(
remoteRuntime,
undefined,
'always',
undefined,
false,
selectedRuntimes
)
const projectId = '00000000-0000-4000-8000-000000000760'
harness.assistantDatabase.getProject.mockReturnValue({
id: projectId,
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000761',
remoteRootPath: '/srv/project'
}
})
const requestId = '00000000-0000-4000-8000-000000000762'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'managed-ssh-start-ambiguity',
projectId,
runtimeSelection: { provider: 'opencode' },
prompt: 'start once',
workMode: 'execute',
knowledgeLibraryIds: [],
currentUserMessageId:
'00000000-0000-4000-8000-000000000763',
currentAssistantMessageId:
'00000000-0000-4000-8000-000000000764'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(
requestId,
'interrupted',
'prompt start response was lost'
)
)
await harness.dispose()
})
it('reserves a Conversation while its Runtime is resolving', async () => {
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
let resolveRuntime:
| ((value: typeof runtime) => void)
| undefined
const runtimeResolution = new Promise<typeof runtime>((resolve) => {
resolveRuntime = resolve
})
const selectedRuntimes = {
getRuntime: vi.fn(() => runtimeResolution)
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
selectedRuntimes
)
const conversationId =
'00000000-0000-4000-8000-000000000724'
const firstRun = harness.handler?.(
trustedEvent(harness.webContents),
{
requestId: '00000000-0000-4000-8000-000000000728',
conversationId,
runtimeSelection: { provider: 'auto' },
prompt: '等待 Runtime',
workMode: 'ask',
knowledgeLibraryIds: []
}
)
await vi.waitFor(() =>
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
)
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000729',
conversationId,
runtimeSelection: { provider: 'auto' },
prompt: '不能并发',
workMode: 'ask',
knowledgeLibraryIds: []
})
).rejects.toThrow('当前对话已有执行中的请求')
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
expect(() =>
harness.branchHandler?.(trustedEvent(harness.webContents), {
sourceConversationId: conversationId,
title: '等待 Runtime · 分支'
})
).toThrow('当前会话仍有正在执行的请求')
expect(
harness.assistantDatabase.branchLocalConversation
).not.toHaveBeenCalled()
resolveRuntime?.(runtime)
await firstRun
await harness.dispose()
})
it('interrupts the active response and promotes the selected queue item', async () => {
const runtimeStarted = vi.fn()
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
async *run(
request: { requestId: string },
signal: AbortSignal
) {
runtimeStarted(request.requestId)
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => resolve(), {
once: true
})
})
if (signal.aborted) {
throw signal.reason
}
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
const conversationId =
'00000000-0000-4000-8000-000000000725'
const item = {
id: '00000000-0000-4000-8000-000000000726',
conversationId,
source: 'user' as const,
label: '优先执行',
createdAt: '2026-08-20T09:01:00.000Z'
}
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt: item.label,
attachments: [],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto' as const
}
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
item
)
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({
source: 'user',
item,
payloadJson: JSON.stringify(input)
})
electronMocks.handlers.get(
ipcChannels.conversationQueueReady
)?.(trustedEvent(harness.webContents), conversationId)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000727',
conversationId,
prompt: '当前回复',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
electronMocks.handlers.get(
ipcChannels.conversationQueueInterruptAndRun
)?.(trustedEvent(harness.webContents), item.id)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item, input }
)
)
expect(
harness.assistantDatabase.claimConversationQueueItem
).toHaveBeenCalledWith(conversationId, item.id)
await harness.dispose()
})
it('dispatches and accepts a queued user message through the renderer', async () => {
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
const conversationId =
'00000000-0000-4000-8000-000000000731'
const itemId = '00000000-0000-4000-8000-000000000732'
const attachmentId =
'00000000-0000-4000-8000-000000000734'
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt: '排队发送',
attachments: [
{
id: attachmentId,
name: 'queued.txt',
size: 6,
preview: 'queued',
kind: 'text' as const
}
],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto' as const
}
const item = {
id: itemId,
conversationId,
source: 'user' as const,
label: input.prompt,
createdAt: '2026-08-19T00:01:00.000Z'
}
harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue(
item
)
harness.assistantDatabase.listConversationQueueItems
.mockReturnValueOnce([item])
.mockReturnValue([])
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({
source: 'user',
item,
payloadJson: JSON.stringify({
input,
serializedContexts: '[]'
})
})
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
item
)
const enqueueHandler = electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)
expect(
enqueueHandler?.(trustedEvent(harness.webContents), input)
).toEqual(item)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item, input }
)
)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000733',
conversationId,
queueItemId: itemId,
runtimeSelection: input.runtimeSelection,
prompt: input.prompt,
workMode: input.workMode,
knowledgeLibraryIds: []
})
expect(
harness.assistantDatabase.completeConversationUserQueueItem
).toHaveBeenCalledWith(itemId)
expect(harness.contextManager.serializeForQueue).toHaveBeenCalledWith([
attachmentId
])
expect(harness.contextManager.remove).toHaveBeenCalledWith(
attachmentId
)
expect(harness.contextManager.restoreFromQueue).toHaveBeenCalledWith(
'[]'
)
await harness.dispose()
})
it('releases the queue reservation when a dispatched run cannot start', async () => {
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
const conversationId =
'00000000-0000-4000-8000-000000000745'
const itemId = '00000000-0000-4000-8000-000000000746'
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt: '需要重新排队',
attachments: [],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto' as const
}
const item = {
id: itemId,
conversationId,
source: 'user' as const,
label: input.prompt,
createdAt: '2026-08-21T00:02:00.000Z'
}
harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue(
item
)
harness.assistantDatabase.listConversationQueueItems
.mockReturnValueOnce([item])
.mockReturnValue([])
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce(
{
source: 'user',
item,
payloadJson: JSON.stringify({
input,
serializedContexts: '[]'
})
}
)
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
item
)
harness.assistantDatabase.createTask.mockImplementationOnce(() => {
throw new Error('会话记录不可用')
})
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), input)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item, input }
)
)
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000747',
conversationId,
queueItemId: itemId,
runtimeSelection: input.runtimeSelection,
prompt: input.prompt,
workMode: input.workMode,
knowledgeLibraryIds: []
})
).rejects.toThrow('会话记录不可用')
expect(
harness.assistantDatabase.releaseConversationUserQueueItem
).toHaveBeenCalledWith(itemId)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000748',
conversationId,
runtimeSelection: { provider: 'auto' },
prompt: '失败后的新消息',
workMode: 'ask',
knowledgeLibraryIds: []
})
await harness.dispose()
})
it('dispatches a message queued during a run that later fails', async () => {
let failRun: ((error: Error) => void) | undefined
const runtimeStarted = vi.fn()
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
// eslint-disable-next-line require-yield
async *run(request: { requestId: string }) {
runtimeStarted(request.requestId)
throw await new Promise<Error>((resolve) => {
failRun = resolve
})
}
}
const harness = createHarness(runtime)
const conversationId =
'00000000-0000-4000-8000-000000000751'
const createItem = (id: string, prompt: string) => {
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt,
attachments: [],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto' as const
}
return {
input,
item: {
id,
conversationId,
source: 'user' as const,
label: prompt,
createdAt: '2026-08-21T00:03:00.000Z'
}
}
}
const first = createItem(
'00000000-0000-4000-8000-000000000752',
'会失败的消息'
)
const second = createItem(
'00000000-0000-4000-8000-000000000753',
'失败之后的新消息'
)
harness.assistantDatabase.enqueueConversationUserInput
.mockReturnValueOnce(first.item)
.mockReturnValueOnce(second.item)
harness.assistantDatabase.listConversationQueueItems
.mockReturnValueOnce([first.item])
.mockReturnValueOnce([second.item])
.mockReturnValue([])
harness.assistantDatabase.claimConversationQueueItem
.mockReturnValueOnce({
source: 'user',
item: first.item,
payloadJson: JSON.stringify({
input: first.input,
serializedContexts: '[]'
})
})
.mockReturnValueOnce({
source: 'user',
item: second.item,
payloadJson: JSON.stringify({
input: second.input,
serializedContexts: '[]'
})
})
harness.assistantDatabase.getConversationQueueItem.mockImplementation(
(id: string) =>
id === first.item.id ? first.item : second.item
)
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), first.input)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item: first.item, input: first.input }
)
)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000754',
conversationId,
queueItemId: first.item.id,
runtimeSelection: first.input.runtimeSelection,
prompt: first.input.prompt,
workMode: first.input.workMode,
knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
// The next message arrives while the run is still in flight, so the
// renderer never reports readiness for it afterwards.
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), second.input)
failRun?.(new Error('模型接口请求失败'))
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000754',
'failed',
expect.any(String)
)
)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item: second.item, input: second.input }
)
)
await harness.dispose()
})
it('publishes Runtime usage as context metrics with one settings read', async () => {
const runtime = {
runtimeId: 'continue',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
for (const [index, inputTokens] of [100, 120].entries()) {
yield {
requestId: request.requestId,
type: 'model-usage',
callId: `continue-call-${index}`,
runtime: 'continue',
provider: 'anthropic',
model: 'summary-model',
inputTokens,
outputTokens: 20,
cacheReadTokens: 10,
cacheWriteTokens: 5
} as const
}
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
harness.getResolvedSettings.mockResolvedValue({
toolApproval: 'always',
subagentSmartRoutingEnabled: false,
continueModelProfile: {
contextWindowTokens: 32_000
},
contextCompression: {
triggerTokens: 20_000
}
})
const requestId = '00000000-0000-4000-8000-000000000020'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'continue-context',
prompt: 'report context usage',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
const metrics = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent)
.map(([, event]) => event)
.filter(
(event): event is AgentEvent =>
(event as AgentEvent).type === 'context-metrics'
)
expect(metrics).toEqual([
{
requestId,
type: 'context-metrics',
contextTokens: 115,
effectiveTriggerTokens: 32_000,
contextWindowTokens: 32_000,
compressionEnabled: false,
source: 'provider',
basis: 'model-call'
},
{
requestId,
type: 'context-metrics',
contextTokens: 135,
effectiveTriggerTokens: 32_000,
contextWindowTokens: 32_000,
compressionEnabled: false,
source: 'provider',
basis: 'model-call'
}
])
expect(harness.getResolvedSettings).toHaveBeenCalledOnce()
await harness.dispose()
})
it('rejects unknown knowledge scope and creates no capability for empty scope', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
{
database: {
listKnowledgeBases: vi.fn(() => [
{ id: libraryId, name: 'Known' }
])
}
},
knowledgeGateway
)
const event = trustedEvent(harness.webContents)
await expect(
harness.handler?.(event, {
requestId: '00000000-0000-4000-8000-000000000021',
conversationId: 'unknown-scope',
prompt: 'test',
workMode: 'ask',
knowledgeLibraryIds: [
'22222222-2222-4222-8222-222222222222'
]
})
).rejects.toThrow('不存在的知识库')
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
await harness.handler?.(event, {
requestId: '00000000-0000-4000-8000-000000000022',
conversationId: 'empty-scope',
prompt: 'test',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000022',
'completed'
)
)
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
await harness.dispose()
})
it('grants read-only Magic Notes tools in Ask and write tools in Execute', async () => {
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => {
const grantCallCount = knowledgeGateway.grant.mock.calls.length
return grantCallCount === 1
? ['note_list', 'note_get', 'note_search']
: [
'note_list',
'note_get',
'note_search',
'note_create',
'note_update',
'note_entry_create',
'note_entry_update',
'note_entry_delete',
'note_delete'
]
}),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true
)
const event = trustedEvent(harness.webContents)
const askRequestId = '00000000-0000-4000-8000-000000000023'
const executeRequestId = '00000000-0000-4000-8000-000000000024'
await harness.handler?.(event, {
requestId: askRequestId,
conversationId: 'notes-read',
prompt: '读取笔记',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
askRequestId,
'completed'
)
)
await harness.handler?.(event, {
requestId: executeRequestId,
conversationId: 'notes-write',
prompt: '创建笔记',
workMode: 'execute',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
executeRequestId,
'completed'
)
)
expect(knowledgeGateway.grant).toHaveBeenNthCalledWith(
1,
askRequestId,
[],
expect.any(AbortSignal),
'read'
)
expect(knowledgeGateway.grant).toHaveBeenNthCalledWith(
2,
executeRequestId,
[],
expect.any(AbortSignal),
'write'
)
await harness.dispose()
})
it('does not grant a built-in MCP that is disabled or unassigned for the runtime', async () => {
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn(() => 'must-not-be-granted'),
getAvailableToolNames: vi.fn(() => ['note_list']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [
'knowledge-base'
])
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true,
undefined,
{ getEnabledBuiltinMcpServerIds }
)
const requestId = '00000000-0000-4000-8000-000000000025'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'disabled-notes',
prompt: '读取笔记',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledWith('model')
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
await harness.dispose()
})
it('grants the assigned built-in browser only to managed Runtime Execute', async () => {
const receivedRequests: Array<{
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
}> = []
const runtime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
supportsScopedDataTools: true,
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
}) {
receivedRequests.push(request)
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn(() => 'browser-capability'),
getAvailableToolNames: vi.fn(() => [
'browser_navigate',
'browser_snapshot'
]),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [
'builtin-browser'
])
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds }
)
const event = trustedEvent(harness.webContents)
const askRequestId = '00000000-0000-4000-8000-000000000026'
const executeRequestId = '00000000-0000-4000-8000-000000000027'
await harness.handler?.(event, {
requestId: askRequestId,
conversationId: 'browser-ask',
prompt: '只回答',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
askRequestId,
'completed'
)
)
await harness.handler?.(event, {
requestId: executeRequestId,
conversationId: 'browser-execute',
prompt: '打开网页',
workMode: 'execute',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
executeRequestId,
'completed'
)
)
expect(knowledgeGateway.grant).toHaveBeenCalledOnce()
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
executeRequestId,
[],
expect.any(AbortSignal),
'none',
undefined,
'browser-execute'
)
expect(receivedRequests[0]?.knowledgeCapabilityToken).toBeUndefined()
expect(receivedRequests[1]).toMatchObject({
knowledgeCapabilityToken: 'browser-capability',
trustedInstructions: expect.stringContaining('browser_navigate')
})
await harness.dispose()
})
it.each(['ask', 'execute'] as const)(
'does not grant or advertise scoped data tools to external OpenCode in %s mode',
async (workMode) => {
let receivedRequest:
| {
knowledgeCapabilityToken?: string
trustedInstructions?: string
}
| undefined
const externalOpenCode = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
supportsScopedDataTools: false,
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn(() => 'must-not-be-granted'),
getAvailableToolNames: vi.fn(() => ['note_list']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
externalOpenCode,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true
)
const requestId = '00000000-0000-4000-8000-000000000025'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'external-opencode',
prompt: '读取笔记',
workMode,
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined()
expect(receivedRequest?.trustedInstructions).not.toContain(
'note_list'
)
expect(receivedRequest?.trustedInstructions).not.toContain(
'Available GoodBuddy data tools:'
)
await harness.dispose()
}
)
it('accepts an authorized knowledge library after the first 100 entries', async () => {
const libraries = Array.from({ length: 101 }, (_, index) => ({
id: `00000000-0000-4000-8000-${index
.toString(16)
.padStart(12, '0')}`,
name: `Library ${index}`
}))
const listKnowledgeBases = vi.fn(() => libraries)
const knowledgeGateway = {
grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
{ database: { listKnowledgeBases } },
knowledgeGateway
)
const requestId = '00000000-0000-4000-8000-000000000024'
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'later-library',
prompt: 'search',
workMode: 'ask',
knowledgeLibraryIds: [libraries[100]!.id]
})
).resolves.toBeUndefined()
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(listKnowledgeBases).toHaveBeenCalledWith(500)
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
requestId,
[libraries[100]!.id],
expect.any(AbortSignal),
'none'
)
await harness.dispose()
})
it('emits drained knowledge references immediately before done', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
const reference = {
libraryId,
libraryName: 'Known',
documentId: '33333333-3333-4333-8333-333333333333',
documentName: 'Doc',
sourceName: 'Source',
snippet: 'Evidence',
rank: 1
}
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
}) {
expect(request.knowledgeCapabilityToken).toBe('capability')
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => [reference]),
revoke: vi.fn()
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
{
database: {
listKnowledgeBases: vi.fn(() => [
{ id: libraryId, name: 'Known' }
])
}
},
knowledgeGateway
)
const requestId = '00000000-0000-4000-8000-000000000023'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'scoped',
prompt: 'search',
workMode: 'ask',
knowledgeLibraryIds: [libraryId, libraryId]
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
requestId,
[libraryId],
expect.any(AbortSignal),
'none'
)
const publicEvents = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent)
.map(([, payload]) => payload)
expect(publicEvents.slice(-2)).toEqual([
{
requestId,
type: 'source-references',
references: [reference]
},
{ requestId, type: 'done' }
])
expect(knowledgeGateway.revoke).toHaveBeenCalledWith('capability')
await harness.dispose()
})
it('coalesces burst deltas while preserving output and terminal order', async () => {
const deltas = Array.from(
{ length: 100 },
(_, index) => `chunk-${index};`
)
const expectedOutput = deltas.join('')
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
for (const delta of deltas) {
yield {
requestId: request.requestId,
type: 'text',
delta
}
}
yield {
requestId: request.requestId,
type: 'tool',
callId: 'call-burst',
name: 'read',
state: 'completed',
summary: 'read completed'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '00000000-0000-4000-8000-000000000025'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'burst-stream',
prompt: 'stream',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
const persistedEvents =
harness.assistantDatabase.appendTaskEvent.mock.calls
.filter(([taskId]) => taskId === requestId)
.map(([, , payload]) => payload)
const publicEvents = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent)
.map(([, payload]) => payload)
.filter((payload) => payload.requestId === requestId)
expect(persistedEvents).toHaveLength(3)
expect(publicEvents).toHaveLength(4)
expect(persistedEvents.map((event) => event.type)).toEqual([
'text',
'tool',
'done'
])
expect(publicEvents.map((event) => event.type)).toEqual([
'text',
'text',
'tool',
'done'
])
expect(publicEvents[0]).toEqual({
requestId,
type: 'text',
delta: deltas[0]
})
expect(persistedEvents[0]).toEqual({
requestId,
type: 'text',
delta: expectedOutput
})
expect(
publicEvents
.filter(
(
event
): event is Extract<AgentEvent, { type: 'text' }> =>
event.type === 'text'
)
.map((event) => event.delta)
.join('')
).toBe(expectedOutput)
expect(
harness.assistantDatabase.createTextArtifact
).not.toHaveBeenCalled()
await harness.dispose()
})
it('preflights always-retrieve mode and injects bounded untrusted evidence', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
const documentId = '33333333-3333-4333-8333-333333333333'
const chunkId = '44444444-4444-4444-8444-444444444444'
const run = vi.fn(async function* (request: {
requestId: string
prompt: string
trustedInstructions?: string
}) {
expect(request.prompt).toContain(
'BEGIN_UNTRUSTED_KNOWLEDGE_EVIDENCE'
)
expect(request.prompt).toContain('离线部署需要先校验安装包')
expect(request.prompt).toContain('ORIGINAL_USER_REQUEST')
expect(request.prompt).not.toContain('C:\\private')
expect(request.trustedInstructions).toContain(
'untrusted quoted data'
)
yield { requestId: request.requestId, type: 'done' }
})
const retrievalResponse = {
query: '如何离线部署?',
durationMs: 12,
settings: {
version: 1 as const,
topK: 6,
minimumVectorSimilarity: 0,
ftsWeight: 1,
vectorWeight: 1,
graphWeight: 0.8,
candidateMultiplier: 4,
contextMaxCharacters: 16_000,
adjacentChunkCount: 0,
localRerankEnabled: false
},
diagnostics: {
requestedChannels: ['fts' as const],
usedChannels: ['fts' as const],
degradedChannels: [],
candidateCounts: { fts: 1 },
channelDurationMs: { fts: 4 },
vectorScannedCount: 0,
filteredByThresholdCount: 0,
filteredByBudgetCount: 0,
rerank: {
requested: 'none' as const,
used: 'none' as const,
status: 'skipped' as const,
candidateCount: 1,
durationMs: 0
}
},
results: [
{
knowledgeBaseId: libraryId,
documentId,
sourceId: '55555555-5555-4555-8555-555555555555',
chunkId,
documentTitle: '离线部署.md',
sourceDisplayName: '产品手册',
sourceType: 'file' as const,
location: '第 2 节',
snippet: '离线部署需要先校验安装包',
relevance: 0.9,
rank: 1,
channels: ['fts' as const],
scores: {
ftsRank: 1,
fusedScore: 0.8
}
}
],
context: {
characterCount: 13,
truncated: false,
groups: [
{
resultChunkId: chunkId,
chunkIds: [chunkId],
documentId,
content: '离线部署需要先校验安装包',
characterCount: 13,
truncated: false
}
]
}
}
const retrieveMany = vi.fn(async () => [
{ knowledgeBaseId: libraryId, response: retrievalResponse }
])
const knowledgeGateway = {
grant: vi.fn(() => 'capability'),
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
{
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
run
},
undefined,
'always',
undefined,
false,
undefined,
{
database: {
listKnowledgeBases: vi.fn(() => [
{ id: libraryId, name: '产品知识' }
])
},
retrieveMany
},
knowledgeGateway
)
const requestId = '00000000-0000-4000-8000-000000000024'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'always-retrieve',
prompt: '如何离线部署?',
workMode: 'ask',
knowledgeLibraryIds: [libraryId],
knowledgeRetrievalMode: 'always'
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(retrieveMany).toHaveBeenCalledWith(
[libraryId],
'如何离线部署?',
expect.any(AbortSignal)
)
const publicEvents = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent)
.map(([, payload]) => payload)
expect(publicEvents).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'knowledge-retrieval',
state: 'searching'
}),
expect.objectContaining({
type: 'knowledge-retrieval',
state: 'succeeded',
resultCount: 1
}),
expect.objectContaining({
type: 'source-references',
references: [
expect.objectContaining({
chunkId,
documentId
})
]
})
])
)
expect(run).toHaveBeenCalledOnce()
await harness.dispose()
})
it('cancels an active full-library rebuild through its scoped controller', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
let resolveRebuild:
| ((value: { rebuilt: number; failed: number }) => void)
| undefined
const rebuildLibrary = vi.fn(
() =>
new Promise<{ rebuilt: number; failed: number }>(
(resolve) => {
resolveRebuild = resolve
}
)
)
const cancelLibraryRebuild = vi.fn(() => true)
const harness = createHarness(
{
capability: 'chat',
supportsToolExecution: true
},
undefined,
'always',
undefined,
false,
undefined,
{
database: { listKnowledgeBases: vi.fn(() => []) },
cancelLibraryRebuild,
rebuildLibrary
}
)
const event = trustedEvent(harness.webContents)
const rebuild = harness.knowledgeRebuildHandler?.(event, {
knowledgeBaseId: libraryId
}) as Promise<unknown>
await vi.waitFor(() => expect(rebuildLibrary).toHaveBeenCalledOnce())
expect(
await Promise.resolve(
harness.knowledgeCancelRebuildHandler?.(event, libraryId)
)
).toBe(true)
expect(cancelLibraryRebuild).toHaveBeenCalledWith(libraryId)
resolveRebuild?.({ rebuilt: 0, failed: 0 })
await expect(rebuild).resolves.toEqual({ rebuilt: 0, failed: 0 })
await harness.dispose()
})
it('returns no results for an explicitly empty knowledge search scope', async () => {
const searchHybridMany = vi.fn(() => {
throw new Error('must not search')
})
const harness = createHarness(
{
capability: 'chat',
supportsToolExecution: true
},
undefined,
'always',
undefined,
false,
undefined,
{
database: { listKnowledgeBases: vi.fn(() => []) },
searchHybridMany
}
)
await expect(
harness.knowledgeSearchHandler?.(
trustedEvent(harness.webContents),
{ libraryIds: [], query: 'anything' }
)
).resolves.toEqual([])
expect(searchHybridMany).not.toHaveBeenCalled()
await harness.dispose()
})
it('routes status and concurrent conversations to their selected runtimes', async () => {
const firstProfileId = '00000000-0000-4000-8000-000000000001'
const secondProfileId = '00000000-0000-4000-8000-000000000002'
const firstSelection = {
provider: 'model' as const,
profileId: firstProfileId
}
const secondSelection = {
provider: 'model' as const,
profileId: secondProfileId
}
const firstRun = vi.fn()
const secondRun = vi.fn()
const createRuntime = (
label: string,
run: typeof firstRun
): Record<string, unknown> => ({
runtimeId: 'model',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(async () => ({
id: 'model',
label,
available: true,
supportsToolExecution: true
})),
dispose: vi.fn(async () => undefined),
async *run(request: { requestId: string; conversationId: string }) {
run(request)
yield { requestId: request.requestId, type: 'done' }
}
})
const firstRuntime = createRuntime('model-one', firstRun)
const secondRuntime = createRuntime('model-two', secondRun)
const selectedRuntimes = {
getStatus: vi.fn(async () => ({
id: 'model',
label: 'model-two',
available: true,
supportsToolExecution: true
})),
getRuntime: vi.fn(async (selection: typeof firstSelection) =>
selection.profileId === firstProfileId
? firstRuntime
: secondRuntime
),
releaseConversation: vi.fn(async () => undefined)
}
const fallbackRuntime = {
runtimeId: 'model',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(async () => ({
id: 'model',
label: 'fallback',
available: true,
supportsToolExecution: true
})),
run: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const harness = createHarness(
fallbackRuntime,
undefined,
'always',
undefined,
false,
selectedRuntimes
)
const event = trustedEvent(harness.webContents)
await expect(
harness.statusHandler?.(event, secondSelection)
).resolves.toEqual(
expect.objectContaining({ label: 'model-two' })
)
expect(selectedRuntimes.getStatus).toHaveBeenCalledWith(
secondSelection
)
harness.getResolvedSettings.mockResolvedValueOnce({
provider: 'opencode',
defaultModelProfileId: firstProfileId,
opencodeBaseUrl: '',
opencodeEmbedded: true,
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' }
})
await expect(
harness.statusHandler?.(event)
).resolves.toEqual(
expect.objectContaining({ label: 'model-two' })
)
expect(selectedRuntimes.getStatus).toHaveBeenLastCalledWith({
provider: 'opencode'
})
expect(fallbackRuntime.getStatus).not.toHaveBeenCalled()
await Promise.all([
harness.handler?.(event, {
requestId: '00000000-0000-4000-8000-000000000011',
conversationId: 'conversation-one',
projectId: '00000000-0000-4000-8000-000000000101',
prompt: 'first request',
workMode: 'ask',
runtimeSelection: firstSelection
}),
harness.handler?.(event, {
requestId: '00000000-0000-4000-8000-000000000012',
conversationId: 'conversation-two',
prompt: 'second request',
workMode: 'ask',
runtimeSelection: secondSelection
})
])
await vi.waitFor(() => {
expect(firstRun).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: 'conversation-one',
runtimeSelection: firstSelection
})
)
expect(secondRun).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: 'conversation-two',
runtimeSelection: secondSelection
})
)
})
expect(fallbackRuntime.run).not.toHaveBeenCalled()
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
firstSelection,
expect.objectContaining({
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
})
)
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
secondSelection,
undefined
)
await harness.dispose()
})
it('rejects an active duplicate before resolving Runtime or settings again', async () => {
let releaseRun!: () => void
const runReleased = new Promise<void>((resolve) => {
releaseRun = resolve
})
let markRunStarted!: () => void
const runStarted = new Promise<void>((resolve) => {
markRunStarted = resolve
})
const selectedRuntime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
markRunStarted()
await runReleased
yield { requestId: request.requestId, type: 'done' }
}
}
const selectedRuntimes = {
getRuntime: vi.fn(async () => selectedRuntime),
getStatus: vi.fn(),
releaseConversation: vi.fn(async () => undefined)
}
const getWebSearchCapabilityStatus = vi.fn(async () => ({
enabled: true
}))
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [])
const harness = createHarness(
selectedRuntime,
undefined,
'always',
undefined,
false,
selectedRuntimes,
undefined,
undefined,
false,
undefined,
{
getWebSearchCapabilityStatus,
getEnabledBuiltinMcpServerIds
}
)
const event = trustedEvent(harness.webContents)
const request = {
requestId: '00000000-0000-4000-8000-000000000013',
conversationId: 'duplicate-request',
projectId: '00000000-0000-4000-8000-000000000101',
prompt: 'run once',
workMode: 'ask' as const
}
await harness.handler?.(event, request)
await runStarted
await expect(harness.handler?.(event, request)).rejects.toThrow(
'请求正在执行'
)
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
expect(harness.getApplicationSettings).toHaveBeenCalledOnce()
expect(getWebSearchCapabilityStatus).toHaveBeenCalledOnce()
expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledOnce()
expect(harness.contextManager.enrichRequest).toHaveBeenCalledOnce()
releaseRun()
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
request.requestId,
'completed'
)
)
await harness.dispose()
})
it('starts independent interactive request lookups concurrently after enrichment', async () => {
let resolveApplicationSettings!: (value: {
magicNotesEnabled: boolean
}) => void
const applicationSettings = new Promise<{
magicNotesEnabled: boolean
}>((resolve) => {
resolveApplicationSettings = resolve
})
let resolveWebSearch!: (value: { enabled: boolean }) => void
const webSearch = new Promise<{ enabled: boolean }>((resolve) => {
resolveWebSearch = resolve
})
let resolveRuntimeSettings!: (value: {
workspacePath: string
}) => void
const runtimeSettings = new Promise<{
workspacePath: string
}>((resolve) => {
resolveRuntimeSettings = resolve
})
let resolveBuiltinMcpServers!: (value: never[]) => void
const builtinMcpServers = new Promise<never[]>((resolve) => {
resolveBuiltinMcpServers = resolve
})
const getWebSearchCapabilityStatus = vi.fn(() => webSearch)
const getEnabledBuiltinMcpServerIds = vi.fn(
() => builtinMcpServers
)
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield { requestId: request.requestId, type: 'done' }
}
}
const goodbuddyConfigService = {
takePendingReload: vi.fn(() => 'none'),
revokeRequest: vi.fn(),
clear: vi.fn()
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
undefined,
false,
goodbuddyConfigService,
{
getWebSearchCapabilityStatus,
getEnabledBuiltinMcpServerIds
}
)
harness.getApplicationSettings.mockReturnValueOnce(
applicationSettings
)
harness.getResolvedSettings.mockReturnValueOnce(runtimeSettings)
const requestId = '00000000-0000-4000-8000-000000000014'
const operation = harness.handler?.(
trustedEvent(harness.webContents),
{
requestId,
conversationId: 'concurrent-request-setup',
prompt: 'prepare concurrently',
workMode: 'ask',
knowledgeLibraryIds: []
}
)
await vi.waitFor(() => {
expect(harness.getApplicationSettings).toHaveBeenCalledOnce()
expect(getWebSearchCapabilityStatus).toHaveBeenCalledOnce()
expect(harness.getResolvedSettings).toHaveBeenCalledOnce()
expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledOnce()
})
const enrichmentOrder =
harness.contextManager.enrichRequest.mock.invocationCallOrder[0]
if (enrichmentOrder === undefined) {
throw new Error('Request was not enriched')
}
for (const lookup of [
harness.getApplicationSettings,
getWebSearchCapabilityStatus,
harness.getResolvedSettings,
getEnabledBuiltinMcpServerIds
]) {
const lookupOrder = lookup.mock.invocationCallOrder[0]
if (lookupOrder === undefined) {
throw new Error('Interactive request lookup did not start')
}
expect(enrichmentOrder).toBeLessThan(lookupOrder)
}
resolveApplicationSettings({ magicNotesEnabled: false })
resolveWebSearch({ enabled: false })
resolveRuntimeSettings({ workspacePath: 'C:\\Workspace' })
resolveBuiltinMcpServers([])
await expect(operation).resolves.toBeUndefined()
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
await harness.dispose()
})
it('aborts active work and clears browser sessions before assistant data', async () => {
const lifecycle: string[] = []
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
_request: unknown,
signal: AbortSignal
): AsyncGenerator<never, void, void> {
markStarted()
await new Promise<void>((_resolve, reject) => {
signal.addEventListener(
'abort',
() => {
lifecycle.push('aborted')
reject(signal.reason)
},
{ once: true }
)
})
yield undefined as never
}
}
const harness = createHarness(runtime, async () => {
lifecycle.push('browser-cleared')
})
vi.mocked(
harness.assistantDatabase.clearAssistantData
).mockImplementation(() => {
lifecycle.push('assistant-cleared')
})
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-clear',
prompt: 'keep running',
workMode: 'execute'
})
await started
await harness.clearHandler?.(trustedEvent(harness.webContents))
expect(lifecycle).toEqual([
'aborted',
'browser-cleared',
'assistant-cleared'
])
await harness.dispose()
})
it('coalesces concurrent local-data clear requests', async () => {
let releaseClear!: () => void
const clearBlocked = new Promise<void>((resolve) => {
releaseClear = resolve
})
const onBeforeClearLocalData = vi.fn(async () => {
await clearBlocked
})
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn()
}
const harness = createHarness(runtime, onBeforeClearLocalData)
const event = trustedEvent(harness.webContents)
const firstClear = harness.clearHandler?.(event)
const secondClear = harness.clearHandler?.(event)
expect(firstClear).toBe(secondClear)
await vi.waitFor(() => {
expect(onBeforeClearLocalData).toHaveBeenCalledOnce()
})
await expect(
harness.handler?.(event, {
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-during-clear',
prompt: 'do not start',
workMode: 'execute'
})
).rejects.toThrow('本地数据维护期间暂不接受新任务')
releaseClear()
await expect(firstClear).resolves.toBeUndefined()
expect(
harness.assistantDatabase.clearAssistantData
).toHaveBeenCalledOnce()
await harness.dispose()
})
it('marks a request failed when a tool fails before runtime done', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'tool',
callId: 'call-1',
name: 'write',
state: 'failed',
summary: 'OpenCode 工具:write',
error: 'write path denied'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'write a file',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'failed',
'write 工具执行失败:write path denied'
)
)
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(requestId, 'completed')
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({
requestId,
type: 'error',
status: 'failed',
message: 'write 工具执行失败:write path denied'
})
)
await harness.dispose()
})
it('allows a completed request after a recoverable tool failure', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'tool',
callId: 'call-recoverable',
name: '浏览器输入',
state: 'recoverable',
summary: '直连模型工具需要刷新后重试:浏览器输入'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'retry browser input',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(
requestId,
'failed',
expect.any(String)
)
await harness.dispose()
})
it('does not fail a completed native subagent for its provisional Task tool', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'tool',
callId: 'call-task-1',
name: 'task',
state: 'running',
summary: 'OpenCode 工具:task'
}
yield {
requestId: request.requestId,
type: 'subagent',
childTaskId: '00000000-0000-4000-8000-000000000601',
expertId: '00000000-0000-4000-8000-000000000701',
expertName: 'explore',
routingMode: 'native',
runtimeCallId: 'call-task-1',
state: 'completed',
output: 'Desktop files inspected.'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'inspect the desktop',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(
requestId,
'failed',
expect.any(String)
)
await harness.dispose()
})
it.each(['opencode', 'continue'] as const)(
'preserves read-only Ask mode at the %s Runtime boundary',
async (runtimeId) => {
let received:
| {
request: { workMode?: string }
authorize: unknown
}
| undefined
const runtime = {
runtimeId,
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
request: { requestId: string; workMode?: string },
_signal: AbortSignal,
authorize: unknown
) {
received = { request, authorize }
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'run the task',
workMode: 'ask'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(received?.request.workMode).toBe('ask')
expect(received?.authorize).toBeUndefined()
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(
harness.assistantDatabase.createTask
).toHaveBeenCalledWith(
expect.objectContaining({ id: requestId, workMode: 'ask' })
)
await harness.dispose()
}
)
it('keeps Ask fail-closed and auto-allows DeepSeek Harness Execute tools', async () => {
const receivedAuthorizers: unknown[] = []
const executeDecisions: string[] = []
const runtime = {
runtimeId: 'deepseek-harness',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
request: { requestId: string; workMode?: string },
_signal: AbortSignal,
authorize?: (request: {
scopeKey: string
title: string
description: string
}) => Promise<string>
) {
receivedAuthorizers.push(authorize)
if (request.workMode === 'execute') {
executeDecisions.push(
(await authorize?.({
scopeKey: 'deepseek-harness:write_file',
title: '写入文件',
description: '主机工具执行'
})) ?? 'missing'
)
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
harness.approvalBroker.request.mockResolvedValue('once')
for (const [index, workMode] of (
['ask', 'execute'] as const
).entries()) {
const requestId = `3f496642-f47d-4e0a-8944-a32c77b0d6e${index}`
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: `conversation-${index}`,
prompt: 'run the task',
workMode
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
}
expect(receivedAuthorizers).toEqual([
expect.any(Function),
expect.any(Function)
])
expect(executeDecisions).toEqual(['once'])
await expect(
(
receivedAuthorizers[0] as (
request: Record<string, string>
) => Promise<string>
)({
scopeKey: 'deepseek-harness:write_file',
title: '写入文件',
description: 'must be denied'
})
).resolves.toBe('deny')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
await harness.dispose()
})
it.each(['model', 'opencode'] as const)(
'normalizes legacy interactive Plan requests to Ask for %s',
async (runtimeId) => {
let receivedRequest:
| { requestId: string; prompt: string; workMode?: string }
| undefined
const runtime = {
runtimeId,
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: {
requestId: string
prompt: string
workMode?: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'draft a plan',
workMode: 'plan'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(receivedRequest?.workMode).toBe('ask')
expect(receivedRequest?.prompt).toContain('Work mode: Ask.')
expect(receivedRequest?.prompt).not.toContain('Work mode: Plan.')
expect(
harness.assistantDatabase.createTask
).toHaveBeenCalledWith(
expect.objectContaining({ id: requestId, workMode: 'ask' })
)
await harness.dispose()
}
)
it('routes eligible Ask requests through the persisted smart expert service and publishes child events', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
run: vi.fn()
}
const childTaskId = '00000000-0000-4000-8000-000000000099'
const expert = {
id: '00000000-0000-4000-8000-000000000001',
name: '研究专家',
description: '',
systemInstructions: 'Analyze evidence.',
routingKeywords: ['资料分析'],
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
const subagentService = {
run: vi.fn(async (input: {
parentRequest: { requestId: string }
onEvent: (event: Record<string, unknown>) => void
}) => {
for (const state of ['queued', 'running', 'completed']) {
input.onEvent({
requestId: input.parentRequest.requestId,
type: 'subagent',
childTaskId,
expertId: expert.id,
expertName: expert.name,
routingMode: 'smart',
state
})
}
return { childTaskId, output: '专家结果' }
}),
cancelAll: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
subagentService,
true
)
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
expert
])
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-smart',
prompt: '请做资料分析',
workMode: 'ask',
smartRouting: true
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(runtime.run).not.toHaveBeenCalled()
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(subagentService.run).toHaveBeenCalledWith(
expect.objectContaining({ expert, routingMode: 'smart' })
)
expect(harness.assistantDatabase.appendTaskEvent).toHaveBeenCalledWith(
requestId,
'subagent',
expect.objectContaining({ childTaskId, state: 'queued' })
)
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({ type: 'subagent', state: 'completed' })
)
await harness.dispose()
})
it.each([
{ workMode: 'ask' as const, persisted: false },
{ workMode: 'execute' as const, persisted: true }
])(
'falls back to the ordinary runtime for ineligible smart routing %#',
async ({ workMode, persisted }) => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: { requestId: string }) {
yield { requestId: request.requestId, type: 'done' }
}
}
const run = vi.spyOn(runtime, 'run')
const subagentService = {
run: vi.fn(),
cancelAll: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
subagentService,
persisted
)
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
{
id: '00000000-0000-4000-8000-000000000001',
name: '研究专家',
description: '',
systemInstructions: 'Analyze.',
routingKeywords: ['资料分析'],
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
])
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-fallback',
prompt: '请做资料分析',
workMode,
smartRouting: true
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(run).toHaveBeenCalledOnce()
expect(subagentService.run).not.toHaveBeenCalled()
await harness.dispose()
}
)
it('does not fall back to the ordinary runtime after smart subagent cancellation', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
run: vi.fn()
}
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
const subagentService = {
run: vi.fn((input: { signal: AbortSignal }) => {
markStarted()
return new Promise((_resolve, reject) => {
input.signal.addEventListener(
'abort',
() => reject(input.signal.reason),
{ once: true }
)
})
}),
cancelAll: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
subagentService,
true
)
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
{
id: '00000000-0000-4000-8000-000000000001',
name: '研究专家',
description: '',
systemInstructions: 'Analyze.',
routingKeywords: ['资料分析'],
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
])
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-cancel-smart',
prompt: '请做资料分析',
workMode: 'ask',
smartRouting: true
})
await started
harness.cancelHandler?.(trustedEvent(harness.webContents), requestId)
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'cancelled',
'请求已取消'
)
)
expect(runtime.run).not.toHaveBeenCalled()
await harness.dispose()
})
it('rejects Execute before creating a task on an unsupported runtime', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: false,
getStatus: vi.fn(),
dispose: vi.fn(),
run: vi.fn()
}
const harness = createHarness(runtime)
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'write a file',
workMode: 'execute'
})
).rejects.toThrow('当前 Runtime 不支持工具执行')
expect(harness.assistantDatabase.createTask).not.toHaveBeenCalled()
await harness.dispose()
})
it('bridges channel ask requests to read-only tasks without approval', async () => {
let received:
| {
request: {
requestId: string
conversationId: string
prompt: string
workMode: string
}
authorize?: (request: {
scopeKey: string
title: string
description: string
}) => Promise<string>
}
| undefined
const runtime = {
capability: 'chat',
async *run(
request: {
requestId: string
conversationId: string
prompt: string
workMode: string
},
_signal: AbortSignal,
authorize?: (request: {
scopeKey: string
title: string
description: string
}) => Promise<string>
) {
received = { request, authorize }
yield {
requestId: request.requestId,
type: 'text',
delta: '只读结果'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
await expect(
executor(
{
channel: 'wecom',
eventId: 'event-1',
senderId: 'user-1',
conversationId: 'conversation-1',
conversationType: 'direct',
text: '请只读分析',
mentioned: false,
workMode: 'ask'
},
new AbortController().signal
)
).resolves.toEqual({
status: 'completed',
output: '只读结果'
})
expect(received?.request).toMatchObject({
workMode: 'ask',
prompt: expect.stringContaining('请只读分析')
})
await expect(
received?.authorize?.({
scopeKey: 'model:builtin:workspace_read_text',
title: '读取文件',
description: '不应申请批准'
})
).resolves.toBe('deny')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.getPolicySettings).not.toHaveBeenCalled()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: '企业微信远程请求',
instructions: '请只读分析',
workMode: 'ask',
origin: 'delegation'
})
)
await harness.dispose()
})
it.each([
['weixin', '微信 ClawBot'],
['wecom', '企业微信'],
['dingtalk', '钉钉']
] as const)(
'grants read-only Magic Notes tools to %s channel Ask requests',
async (channel, channelLabel) => {
let receivedRequest:
| {
knowledgeCapabilityToken?: string
prompt: string
trustedInstructions?: string
workMode: string
}
| undefined
const runtime = {
capability: 'chat',
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
prompt: string
trustedInstructions?: string
workMode: string
}) {
receivedRequest = request
yield {
requestId: request.requestId,
type: 'tool',
callId: `call-${channel}-note-list`,
name: 'note_list',
state: 'completed',
summary: '读取笔记列表'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn<KnowledgeGrantMock>(() =>
'channel-notes-capability'
),
getAvailableToolNames: vi.fn(() => [
'note_list',
'note_get',
'note_search'
]),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true
)
vi.mocked(
harness.assistantDatabase.listProjects
).mockReturnValue([
{
id: '00000000-0000-4000-8000-000000000401',
name: channelLabel,
description: `${channelLabel}远程消息与受控任务`,
rootPath: 'C:\\ProjectWorkspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
},
defaultWorkMode: 'ask',
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000001'
},
kind: 'channel',
channel,
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
])
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
await expect(
executor(
{
channel,
eventId: `event-${channel}-notes`,
senderId: 'user-1',
conversationId: `conversation-${channel}-notes`,
conversationType: 'direct',
text: '读取我的笔记',
mentioned: false,
workMode: 'ask'
},
new AbortController().signal
)
).resolves.toMatchObject({ status: 'completed' })
const requestId =
vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0]
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
requestId,
[],
expect.any(AbortSignal),
'read'
)
expect(receivedRequest).toMatchObject({
knowledgeCapabilityToken: 'channel-notes-capability',
workMode: 'ask',
trustedInstructions: expect.stringContaining(
'note_list, note_get, note_search'
)
})
expect(receivedRequest?.prompt).toContain('读取我的笔记')
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
'channel-notes-capability'
)
await harness.dispose()
}
)
it('persists remote media and passes it through the existing context path', async () => {
let receivedRequest:
| {
contextIds?: string[]
prompt: string
}
| undefined
const runtime = {
capability: 'chat',
async *run(request: {
requestId: string
contextIds?: string[]
prompt: string
}) {
receivedRequest = request
yield {
requestId: request.requestId,
type: 'text',
delta: '图片已分析'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
await expect(
executor(
{
channel: 'wecom',
eventId: 'event-media',
senderId: 'user-1',
conversationId: 'conversation-media',
conversationType: 'direct',
text: '',
attachments: [
{
name: '现场.png',
mimeType: 'image/png',
size: 4,
kind: 'image',
dataBase64: 'iVBORw=='
}
],
mentioned: false,
workMode: 'ask'
},
new AbortController().signal
)
).resolves.toEqual({
status: 'completed',
output: '图片已分析'
})
expect(
harness.contextManager.ingestRemoteAttachment
).toHaveBeenCalledWith(
expect.objectContaining({ name: '现场.png' })
)
expect(receivedRequest?.contextIds).toEqual([
'00000000-0000-4000-8000-000000000498'
])
expect(
harness.assistantDatabase.appendRemoteConversationMessage
).toHaveBeenCalledWith(
expect.objectContaining({
role: 'user',
content: '请分析我发送的附件。',
attachments: [
expect.objectContaining({ name: '现场.png' })
]
})
)
expect(harness.contextManager.remove).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000498'
)
await harness.dispose()
})
it('returns a generated image only as a current-task channel attachment', async () => {
const image = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
])
const runtime = {
capability: 'image-generation',
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'generated-image',
mimeType: 'image/png',
data: image.toString('base64'),
title: '结果图'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
await expect(
executor(
{
channel: 'wecom',
eventId: 'event-generated-image',
senderId: 'user-1',
conversationId: 'conversation-generated-image',
conversationType: 'direct',
text: '生成结果图',
mentioned: false,
workMode: 'ask'
},
new AbortController().signal
)
).resolves.toMatchObject({
status: 'completed',
attachments: [
{
name: '结果图.png',
mimeType: 'image/png',
size: image.byteLength,
kind: 'image',
dataBase64: image.toString('base64')
}
],
artifactIds: [
'00000000-0000-4000-8000-000000000499'
]
})
expect(
harness.assistantDatabase.appendRemoteConversationMessage
).toHaveBeenLastCalledWith(
expect.objectContaining({
role: 'assistant',
artifactIds: [
'00000000-0000-4000-8000-000000000499'
]
})
)
await harness.dispose()
})
it('creates a bounded result file only when the remote user explicitly requests one', async () => {
const runtime = {
capability: 'chat',
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'text',
delta: '# 本周报告\n\n已完成。'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
const result = await executor(
{
channel: 'wecom',
eventId: 'event-result-file',
senderId: 'user-1',
conversationId: 'conversation-result-file',
conversationType: 'direct',
text: '请生成一个文件,总结本周进展',
mentioned: false,
workMode: 'ask'
},
new AbortController().signal
)
expect(result).toMatchObject({
status: 'completed',
output: '# 本周报告\n\n已完成。',
attachments: [
{
name: 'GoodBuddy-结果.md',
mimeType: 'text/markdown',
kind: 'file'
}
]
})
expect(
Buffer.from(
result.attachments?.[0]?.dataBase64 ?? '',
'base64'
).toString('utf8')
).toBe('# 本周报告\n\n已完成。')
expect(
harness.assistantDatabase.createTextArtifact
).not.toHaveBeenCalled()
await harness.dispose()
})
it.each([
{
name: 'Runtime status resolution fails',
getStatus: () => Promise.reject(new Error('状态查询失败')),
expectedError: '状态查询失败'
},
{
name: 'Runtime is available without tool execution',
getStatus: () =>
Promise.resolve({
id: 'model',
label: 'Direct model',
available: true,
supportsToolExecution: false
}),
expectedError:
'所选处理后端不支持工具执行,请在消息通道设置中选择 OpenCode、Continue 或支持工具的直连模型'
}
])(
'finalizes remote Execute consistently when $name',
async ({ getStatus, expectedError }) => {
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
getStatus: vi.fn(getStatus),
run: vi.fn()
}
const harness = createHarness(runtime)
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
await expect(
executor(
{
channel: 'wecom',
eventId: 'event-execute-unavailable',
senderId: 'user-1',
conversationId: 'conversation-execute-unavailable',
conversationType: 'direct',
text: '/execute 更新 README',
mentioned: false,
workMode: 'ask'
},
new AbortController().signal
)
).resolves.toEqual({
status: 'failed',
error: expectedError
})
const remoteTask =
harness.assistantDatabase.createTask.mock.calls[0]?.[0]
expect(remoteTask).toEqual(
expect.objectContaining({
projectId: '00000000-0000-4000-8000-000000000401',
conversationId: '00000000-0000-4000-8000-000000000402',
workMode: 'execute',
origin: 'delegation',
visible: false
})
)
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(remoteTask?.id, 'failed', expectedError)
expect(
harness.assistantDatabase.appendRemoteConversationMessage
).toHaveBeenLastCalledWith({
conversationId: '00000000-0000-4000-8000-000000000402',
role: 'assistant',
content: expectedError,
status: '执行不可用'
})
const activities = harness.webContents.send.mock.calls
.filter(
([channel]) =>
channel === ipcChannels.remoteChannelActivity
)
.map(([, activity]) => activity)
expect(activities).toHaveLength(2)
expect(activities[0]).toEqual(
expect.objectContaining({
requestId: remoteTask?.id,
kind: 'request',
status: 'running'
})
)
expect(activities[1]).toEqual({
requestId: remoteTask?.id,
conversationId: '00000000-0000-4000-8000-000000000402',
projectId: '00000000-0000-4000-8000-000000000401',
projectName: '企业微信',
channel: 'wecom',
kind: 'result',
title: '企业微信远程执行不可用',
detail: expectedError,
status: 'failed'
})
expect(runtime.run).not.toHaveBeenCalled()
await harness.dispose()
}
)
it('runs remote Execute immediately with the selected direct model policy', async () => {
let authorization: string | undefined
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
getStatus: vi.fn(async () => ({
id: 'model',
label: 'Direct model',
available: true,
supportsToolExecution: true
})),
async *run(
request: { requestId: string },
_signal: AbortSignal,
authorize: (
request: {
scopeKey: string
title: string
description: string
}
) => Promise<string>
) {
authorization = await authorize({
scopeKey: 'model:builtin:workspace_write_text',
title: '写入文件',
description: '写入 README.md'
})
yield {
requestId: request.requestId,
type: 'text',
delta: '执行完成'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
const reportProgress = vi.fn(async () => undefined)
await expect(
executor(
{
channel: 'wecom',
eventId: 'event-execute-direct',
senderId: 'user-1',
conversationId: 'conversation-execute-direct',
conversationType: 'direct',
text: '/execute 更新 README',
mentioned: false,
workMode: 'ask'
},
new AbortController().signal,
reportProgress
)
).resolves.toEqual({
status: 'completed',
output: '执行完成'
})
expect(authorization).toBe('once')
expect(reportProgress).not.toHaveBeenCalled()
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(
expect.any(String),
'waiting_approval'
)
expect(
harness.assistantDatabase.getOrCreateRemoteConversation
).toHaveBeenCalledWith(
expect.objectContaining({
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000001'
}
})
)
await harness.dispose()
})
it('grants Magic Notes write tools to channel Execute requests', async () => {
let receivedRequest:
| {
knowledgeCapabilityToken?: string
trustedInstructions?: string
workMode: string
}
| undefined
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
getStatus: vi.fn(async () => ({
id: 'model',
label: 'Direct model',
available: true,
supportsToolExecution: true
})),
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
workMode: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn<KnowledgeGrantMock>(() =>
'channel-notes-write-capability'
),
getAvailableToolNames: vi.fn(() => [
'note_list',
'note_get',
'note_search',
'note_create',
'note_update',
'note_entry_create',
'note_entry_update',
'note_entry_delete',
'note_delete'
]),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true
)
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
await expect(
executor(
{
channel: 'wecom',
eventId: 'event-execute-notes',
senderId: 'user-1',
conversationId: 'conversation-execute-notes',
conversationType: 'direct',
text: '/execute 创建一条笔记',
mentioned: false,
workMode: 'ask'
},
new AbortController().signal
)
).resolves.toMatchObject({ status: 'completed' })
const requestId =
vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0]
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
requestId,
[],
expect.any(AbortSignal),
'write'
)
expect(receivedRequest).toMatchObject({
knowledgeCapabilityToken: 'channel-notes-write-capability',
workMode: 'execute',
trustedInstructions: expect.stringContaining('note_create')
})
expect(receivedRequest?.trustedInstructions).toContain(
'note_delete'
)
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
'channel-notes-write-capability'
)
await harness.dispose()
})
it('does not grant or advertise Magic Notes to external OpenCode channels', async () => {
let receivedRequest:
| {
knowledgeCapabilityToken?: string
trustedInstructions?: string
}
| undefined
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
supportsScopedDataTools: false,
getStatus: vi.fn(async () => ({
id: 'opencode',
label: 'External OpenCode',
available: true,
supportsToolExecution: true
})),
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn<KnowledgeGrantMock>(() => 'must-not-be-granted'),
getAvailableToolNames: vi.fn(() => ['note_list']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const harness = createHarness(
selectedRuntime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true
)
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
const result = await executor(
{
channel: 'wecom',
eventId: 'event-external-opencode',
senderId: 'friend',
conversationId: 'external-opencode',
conversationType: 'direct',
text: '读取我的笔记',
mentioned: true,
workMode: 'ask'
},
new AbortController().signal
)
if (result.status === 'failed') {
throw new Error(result.error)
}
expect(result).toMatchObject({ status: 'completed' })
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined()
expect(receivedRequest?.trustedInstructions).not.toContain(
'note_list'
)
await harness.dispose()
})
it('advertises only native read access to remote Agent Runtime Ask requests', async () => {
let receivedRequest:
| {
trustedInstructions?: string
workMode?: string
}
| undefined
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(request: {
requestId: string
trustedInstructions?: string
workMode?: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const selectedRuntimes = {
getRuntime: vi.fn(async () => selectedRuntime),
getStatus: vi.fn(),
releaseConversation: vi.fn(async () => undefined)
}
const harness = createHarness(
{
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
run: vi.fn()
},
undefined,
'always',
undefined,
false,
selectedRuntimes
)
const projectId = '00000000-0000-4000-8000-000000000451'
harness.getApplicationSettings.mockResolvedValue({
magicNotesEnabled: false,
remoteProjectsEnabled: true
} as never)
harness.assistantDatabase.getProject.mockReturnValue({
id: projectId,
name: 'Remote project',
description: '',
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000452',
remoteRootPath: '/srv/project'
},
defaultWorkMode: 'ask',
runtimeSelection: { provider: 'opencode' },
kind: 'user',
status: 'active',
createdAt: '2026-08-25T00:00:00.000Z',
updatedAt: '2026-08-25T00:00:00.000Z'
} as never)
await harness.handler?.(
trustedEvent(harness.webContents),
{
requestId: '00000000-0000-4000-8000-000000000453',
conversationId: 'conversation-remote-ask',
projectId,
prompt: 'Read marker.txt',
workMode: 'ask',
runtimeSelection: { provider: 'opencode' },
knowledgeLibraryIds: []
}
)
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
{ provider: 'opencode' },
expect.objectContaining({
kind: 'ssh',
remoteRootPath: '/srv/project'
})
)
expect(receivedRequest).toMatchObject({ workMode: 'ask' })
expect(receivedRequest?.trustedInstructions).toContain(
'only the native read tool'
)
expect(receivedRequest?.trustedInstructions).toContain(
'Do not call any other tool or make changes'
)
expect(receivedRequest?.trustedInstructions).not.toContain(
'Do not call tools'
)
await harness.dispose()
})
it('routes remote Execute to a configured Agent Runtime without a GoodBuddy approval callback', async () => {
let receivedAuthorize: unknown = 'not-called'
const configuredProfileId =
'00000000-0000-4000-8000-000000000019'
const selectedRuntime = {
runtimeId: 'continue',
capability: 'chat',
supportsToolExecution: true,
getStatus: vi.fn(async () => ({
id: 'continue',
label: 'Continue',
available: true,
supportsToolExecution: true
})),
async *run(
request: { requestId: string },
_signal: AbortSignal,
authorize?: unknown
) {
receivedAuthorize = authorize
yield {
requestId: request.requestId,
type: 'text',
delta: 'Continue 已执行'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const selectedRuntimes = {
getRuntime: vi.fn(async () => selectedRuntime),
getStatus: vi.fn(),
releaseConversation: vi.fn(async () => undefined)
}
const harness = createHarness(
{
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
run: vi.fn()
},
undefined,
'always',
undefined,
false,
selectedRuntimes
)
harness.getResolvedSettings.mockResolvedValue({
toolApproval: 'always',
subagentSmartRoutingEnabled: false,
continueModelProfile: { id: configuredProfileId },
modelProfiles: [{ id: configuredProfileId }]
})
vi.mocked(
harness.assistantDatabase.listProjects
).mockReturnValue([
{
id: '00000000-0000-4000-8000-000000000401',
name: '企业微信',
description: '企业微信远程消息与受控任务',
rootPath: 'C:\\ProjectWorkspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
},
defaultWorkMode: 'execute',
runtimeSelection: { provider: 'continue' },
kind: 'channel',
channel: 'wecom',
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
}
])
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
await expect(
executor(
{
channel: 'wecom',
eventId: 'event-execute-runtime',
senderId: 'user-1',
conversationId: 'conversation-execute-runtime',
conversationType: 'direct',
text: '更新 README',
mentioned: false,
workMode: 'ask'
},
new AbortController().signal
)
).resolves.toEqual({
status: 'completed',
output: 'Continue 已执行'
})
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
{ provider: 'continue', profileId: configuredProfileId },
expect.objectContaining({
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
})
)
expect(
harness.assistantDatabase.getOrCreateRemoteConversation
).toHaveBeenCalledWith(
expect.objectContaining({
runtimeSelection: { provider: 'continue' }
})
)
expect(receivedAuthorize).toBeUndefined()
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
await harness.dispose()
})
it('stops channels before clearing other IPC resources', async () => {
const order: string[] = []
channelMocks.stop.mockImplementationOnce(async () => {
order.push('channel-stop')
})
const harness = createHarness({
capability: 'chat',
run: vi.fn()
})
harness.contextManager.clear.mockImplementation(() => {
order.push('context-clear')
})
await harness.dispose()
expect(order).toEqual(['channel-stop', 'context-clear'])
})
it('authorizes direct-model Execute tools without approval events or broker prompts', async () => {
let receivedAuthorize:
| ((
request: {
scopeKey: string
title: string
description: string
}
) => Promise<string>)
| undefined
let decision: string | undefined
const runtime = {
runtimeId: 'model',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
request: { requestId: string },
_signal: AbortSignal,
authorize: typeof receivedAuthorize
) {
receivedAuthorize = authorize
decision = await authorize?.({
scopeKey: 'model:builtin:workspace_read_text',
title: '允许读取工作区文本?',
description: '读取 README.md'
})
yield {
requestId: request.requestId,
type: 'tool',
callId: 'call-1',
name: '读取工作区文本',
state: 'completed',
summary: '直连模型工具已完成'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: '读取文件',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(receivedAuthorize).toEqual(expect.any(Function))
expect(decision).toBe('once')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(requestId, 'waiting_approval')
expect(harness.webContents.send).not.toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({ type: 'approval' })
)
await harness.dispose()
})
it('denies direct-model Execute tools when the deny-all policy is selected', async () => {
let decision: string | undefined
const runtime = {
runtimeId: 'model',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
request: { requestId: string },
_signal: AbortSignal,
authorize: (
request: {
scopeKey: string
title: string
description: string
}
) => Promise<string>
) {
decision = await authorize({
scopeKey: 'model:builtin:workspace_read_text',
title: '允许读取工作区文本?',
description: '读取 README.md'
})
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime, undefined, 'policy')
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: '读取文件',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(decision).toBe('deny')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(harness.webContents.send).not.toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({ type: 'approval' })
)
await harness.dispose()
})
it('routes local config apply through native approval and denies policy mode', async () => {
for (const [toolApproval, expectedAuthorized] of [
['always', true],
['policy', false]
] as const) {
let authorizeConfigApply:
| ((
event: {
requestId: string
planId: string
summary: string
risk: 'high'
reload: 'after-current-request'
destructive: boolean
},
signal: AbortSignal
) => Promise<boolean>)
| undefined
const requestId = `3f496642-f47d-4e0a-8944-a32c77b0d6e${expectedAuthorized ? '1' : '2'}`
const knowledgeGateway = {
grant: vi.fn(
(
_requestId: string,
_libraryIds: readonly string[],
_signal: AbortSignal,
_magicNotesAccess: string,
config: {
authorizeApply?: typeof authorizeConfigApply
}
) => {
authorizeConfigApply = config.authorizeApply
return 'config-capability'
}
),
getAvailableToolNames: vi.fn(() => [
'goodbuddy_config_capabilities',
'goodbuddy_config_get',
'goodbuddy_config_plan',
'goodbuddy_config_apply'
]),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const goodbuddyConfigService = {
takePendingReload: vi.fn(() => 'none'),
revokeRequest: vi.fn(),
clear: vi.fn()
}
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
const authorized = await authorizeConfigApply?.(
{
requestId: request.requestId,
planId: '11111111-1111-4111-8111-111111111111',
summary: '删除一个 MCP Server',
risk: 'high',
reload: 'after-current-request',
destructive: true
},
new AbortController().signal
)
expect(authorized).toBe(expectedAuthorized)
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(
runtime,
undefined,
toolApproval,
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
goodbuddyConfigService
)
harness.getResolvedSettings.mockResolvedValue({
workspacePath: 'C:\\Workspace'
})
harness.approvalBroker.request.mockResolvedValue('once')
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: `conversation-${requestId}`,
prompt: '删除 MCP',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
if (expectedAuthorized) {
expect(harness.approvalBroker.request).toHaveBeenCalledWith(
expect.objectContaining({
requestId,
conversationId: `goodbuddy-config:${requestId}`,
scopeKey:
'goodbuddy-config:11111111-1111-4111-8111-111111111111',
title: '允许高风险 GoodBuddy 配置变更?',
toolName: 'goodbuddy_config_apply',
allowPermanent: false
}),
expect.any(AbortSignal),
expect.any(Function)
)
} else {
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
}
await harness.dispose()
}
})
it('coalesces config reload until all active requests finish', async () => {
const completions = new Map<string, () => void>()
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
await new Promise<void>((resolve) => {
completions.set(request.requestId, resolve)
})
yield { requestId: request.requestId, type: 'done' }
}
}
const knowledgeGateway = {
grant: vi.fn(() => 'config-capability'),
getAvailableToolNames: vi.fn(() => [
'goodbuddy_config_capabilities'
]),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const goodbuddyConfigService = {
takePendingReload: vi.fn(() => 'after-current-request'),
revokeRequest: vi.fn(),
clear: vi.fn()
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
goodbuddyConfigService
)
harness.getResolvedSettings.mockResolvedValue({
workspacePath: 'C:\\Workspace'
})
const requestIds = [
'3f496642-f47d-4e0a-8944-a32c77b0d6e3',
'3f496642-f47d-4e0a-8944-a32c77b0d6e4'
]
for (const requestId of requestIds) {
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: `conversation-${requestId}`,
prompt: '配置 Runtime',
workMode: 'execute'
})
}
await vi.waitFor(() => expect(completions.size).toBe(2))
completions.get(requestIds[0]!)?.()
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestIds[0], 'completed')
)
expect(harness.onRuntimeSettingsChanged).not.toHaveBeenCalled()
completions.get(requestIds[1]!)?.()
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestIds[1], 'completed')
)
await vi.waitFor(() =>
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
)
expect(goodbuddyConfigService.takePendingReload).toHaveBeenCalledTimes(
2
)
await harness.dispose()
})
it('preserves bounded runtime errors for persistence and renderer delivery', async () => {
const fetchCause = Object.assign(
new Error('connect ECONNREFUSED 127.0.0.1:11434'),
{
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 11434
}
)
const expectedError = [
'fetch failed',
'cause:',
'connect ECONNREFUSED 127.0.0.1:11434',
'code: ECONNREFUSED',
'syscall: connect',
'address: 127.0.0.1',
'port: 11434'
].join('\n')
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: false,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run() {
yield* []
throw new TypeError('fetch failed', { cause: fetchCause })
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'ask',
workMode: 'ask'
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'failed',
expectedError
)
)
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({
message: expectedError
})
)
await harness.dispose()
})
})
describe('registerIpcHandlers Magic Notes analysis', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
channelMocks.stop.mockResolvedValue(undefined)
})
it('persists comments and usage without exposing an analysis task', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-magic-ipc-'))
const database = new AssistantDatabase(
join(directory, 'assistant.sqlite')
)
database.initialize('C:\\Workspace')
const note = database.createMagicNote({
title: 'API 回归测试'
})
const withEntry = database.createMagicNoteEntry({
noteId: note.id,
content: {
version: 1,
ops: [{ insert: '请完成发布清单。\n' }]
},
plainText: '请完成发布清单。'
})
const entry = withEntry.entries[0]!
const releaseConversation = vi.fn(async () => undefined)
const disposeRuntime = vi.fn(async () => undefined)
let analysisRequestId = ''
const analysisRuntime = {
releaseConversation,
dispose: disposeRuntime,
async *run(request: { requestId: string }) {
analysisRequestId = request.requestId
yield {
requestId: request.requestId,
type: 'model-usage',
callId: 'magic-call-1',
runtime: 'model',
provider: 'openai',
model: 'test-model',
inputTokens: 20,
outputTokens: 10,
cacheReadTokens: 0,
cacheWriteTokens: 0
} as const
yield {
requestId: request.requestId,
type: 'text',
delta:
'{"comments":[{"kind":"suggestion","content":"先核对发布材料。"}]}'
} as const
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
runtimeFactoryMocks.createDefaultModelRuntime.mockReturnValue(
analysisRuntime
)
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const disposeHandlers = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{
getResolvedSettings: vi.fn(async () => ({
workspacePath: 'C:\\Workspace'
}))
} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
database,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => undefined)
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
try {
await expect(
electronMocks.handlers.get(ipcChannels.magicNotesAnalyze)?.(
event,
{
entryId: entry.id,
requestId: '00000000-0000-4000-8000-000000000701',
direction: 'general',
format: 'structured'
}
)
).resolves.toMatchObject({
entries: [
{
comments: [
expect.objectContaining({
content: '先核对发布材料。'
})
]
}
]
})
expect(database.listTasks()).toEqual([])
expect(database.getTokenUsageSummary().records).toEqual([
expect.objectContaining({
requestId: analysisRequestId,
provider: 'openai',
model: 'test-model',
totalTokens: 30
})
])
expect(releaseConversation).toHaveBeenCalledWith(
`magic-notes:${entry.id}`
)
expect(disposeRuntime).toHaveBeenCalledOnce()
} finally {
await disposeHandlers()
database.close()
await rm(directory, { recursive: true, force: true })
}
})
})