Files
goodbuddy/src/main/ipc.test.ts
T
mesalogo 83a48a4079 fix: reclaim idle remote runtimes and retire replaced agents
Remote tasks could leave idle Runtime processes behind, and replaced Agent
installations lacked a drain-and-exit lifecycle. Keep the current Agent
resident, let displaced Agents finish existing work and reconnect for its
results, then retire them without restarting obsolete installations.

Reclaim Runtime processes when their last active task finishes, preserve
pending questions and shared active tasks, and restore conversation history
when the next turn starts. Remove the Remote Projects technical preview label
from settings, errors, tests, and current documentation; the feature remains
disabled by default.

Validation recorded during implementation: 213 lifecycle tests and 28 focused
UI/IPC tests passed, as did SettingsPanel and lint. Linux x64 real-Host checks
used 8 provider requests. Broader tests and final typecheck encountered
unrelated concurrent changes; details and coverage limits are documented.

Release note: 远程任务结束后自动释放空闲运行进程,下一轮对话恢复历史;更新后的旧 Agent 等待已有任务完成再退出,当前 Agent 保持常驻。远程项目移除“技术预览”标识,仍默认关闭。
2026-09-23 17:37:14 +08:00

11821 lines
374 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
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,
AssistantSchedule,
ConversationSnapshot,
ConversationQueueItem
} from '../shared/assistant-contracts'
import {
browserTabIdSchema,
defaultRuntimeSettings,
runtimeSettingsInputSchema,
type AgentEvent,
type BrowserLiveState,
type BrowserTabId,
type ConversationQueueDispatch
} from '../shared/contracts'
import type {
SshHostAgentConnectionStatus
} from '../shared/ssh-host-contracts'
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
import { AssistantDatabase } from './assistant/assistant-database'
import { ImageGenerationService } from './agent/image-generation-service'
import type { AgentExecutionRequest } from './agent/runtime'
import { KnowledgeService } from './knowledge/knowledge-service'
import { KnowledgeDatabase } from './knowledge/knowledge-database'
import { BrowserNavigationStoppedError } from './browser/browser-service'
import { RemotePromptCancelledError } from './agent/acp-remote-runtime'
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 = Object.assign(new EventEmitter(), {
id: 7,
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
})
const window = {
webContents,
isDestroyed: vi.fn(() => false),
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 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 setViewport = vi.fn()
const releaseConversation = vi.fn(async () => {})
const createTab = vi.fn(async (conversationId: string) => ({
conversationId,
tabId: '4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
}))
const listTabs = vi.fn(() => [])
const getVisibleTabId = vi.fn(() => undefined)
const acquireTabUsage = vi.fn(
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release: vi.fn()
})
)
const closeTab = vi.fn(async () => {})
const snapshotBrowser = vi.fn(async () => ({
url: 'https://example.com/',
title: 'Example',
nodes: [],
truncated: false
}))
const clickBrowser = vi.fn(async () => {})
const typeBrowser = vi.fn(async () => {})
const selectBrowser = vi.fn(async () => {})
const screenshotBrowser = vi.fn(async () => ({
type: 'image' as const,
mimeType: 'image/jpeg' as const,
data: '/9j/2Q=='
}))
const selectFiles = vi.fn(
async (
_window: unknown,
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, importFiles: 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,
{
createTab,
listTabs,
getVisibleTabId,
acquireTabUsage,
closeTab,
navigate,
back,
reload,
snapshot: snapshotBrowser,
click: clickBrowser,
type: typeBrowser,
select: selectBrowser,
screenshot: screenshotBrowser,
stopLoading,
setViewport,
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))
const importFiles = electronMocks.handlers.get(ipcChannels.contextImportFiles)!
await expect(importFiles(event, { paths: ['C:\\scan.pdf'] })).resolves.toEqual([])
expect(selectFiles).toHaveBeenCalledWith(['C:\\scan.pdf'], expect.any(Function))
expect(() => importFiles(event, { paths: [''] })).toThrow()
expect(() => importFiles({ ...event, senderFrame: {} }, { paths: ['C:\\scan.pdf'] })).toThrow()
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()
const browserTabId = browserTabIdSchema.parse(
'4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
)
const siblingBrowserTabId = browserTabIdSchema.parse(
'0387bd61-3a12-40ce-98d7-ef5d14cc8251'
)
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
workbarInstanceId: browserTabId,
updatedAt: 1
})
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.browserState,
expect.objectContaining({
conversationId: 'browser-conversation',
status: 'ready'
})
)
const ownedStateSendCount = webContents.send.mock.calls.length
browserStateListener?.({
conversationId: 'foreign-conversation',
tabId: browserTabId,
ownerWindowId: 8,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
updatedAt: 1
})
expect(webContents.send).toHaveBeenCalledTimes(ownedStateSendCount)
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'acting',
sessionActive: true,
isLoading: false,
canGoBack: false,
workbarInstanceId: browserTabId,
updatedAt: 2
})
const repeatedFramePayload = webContents.send.mock.calls.at(-1)?.[1]
expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl')
expect(repeatedFramePayload).toHaveProperty('workbarInstanceId', browserTabId)
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: siblingBrowserTabId,
ownerWindowId: 7,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
workbarInstanceId: siblingBrowserTabId,
updatedAt: 2
})
expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty(
'workbarInstanceId', siblingBrowserTabId
)
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'stopped',
sessionActive: false,
isLoading: false,
canGoBack: false,
updatedAt: 3
})
browserStateListener?.({
conversationId: 'browser-conversation',
tabId: browserTabId,
ownerWindowId: 7,
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
workbarInstanceId: browserTabId,
updatedAt: 4
})
expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty(
'workbarInstanceId',
browserTabId
)
await expect(
electronMocks.handlers.get(ipcChannels.browserStop)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toBeUndefined()
expect(releaseConversation).toHaveBeenCalledWith(
'browser-conversation',
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserCreateTab)?.(event, {
conversationId: 'browser-conversation',
workbarInstanceId: siblingBrowserTabId
})
).resolves.toMatchObject({ tabId: browserTabId })
expect(createTab).toHaveBeenCalledWith(
'browser-conversation',
7,
expect.any(AbortSignal),
siblingBrowserTabId
)
await expect(
electronMocks.handlers.get(ipcChannels.browserListTabs)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toEqual([])
expect(listTabs).toHaveBeenCalledWith('browser-conversation', 7)
await expect(
electronMocks.handlers.get(ipcChannels.browserCloseTab)?.(event, {
conversationId: 'browser-conversation',
tabId: browserTabId
})
).resolves.toBeUndefined()
expect(closeTab).toHaveBeenCalledWith(
'browser-conversation',
browserTabId,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
conversationId: 'browser-conversation',
url: 'https://example.com/'
})
).resolves.toBeUndefined()
expect(navigate).toHaveBeenCalledWith(
'browser-conversation',
'https://example.com/',
expect.any(AbortSignal),
undefined,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserBack)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toBeUndefined()
expect(back).toHaveBeenCalledWith(
'browser-conversation',
expect.any(AbortSignal),
undefined,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserReload)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toBeUndefined()
expect(reload).toHaveBeenCalledWith(
'browser-conversation',
expect.any(AbortSignal),
undefined,
7
)
await expect(
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
event,
{ conversationId: 'browser-conversation' }
)
).resolves.toBeUndefined()
expect(stopLoading).toHaveBeenCalledWith(
'browser-conversation',
undefined,
7
)
expect(
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
conversationId: 'browser-conversation',
leaseToken: siblingBrowserTabId,
bounds: { x: 900, y: 120, width: 320, height: 600 }
})
).toBeUndefined()
expect(setViewport).toHaveBeenCalledWith(
'browser-conversation',
{ x: 900, y: 120, width: 320, height: 600 },
undefined,
siblingBrowserTabId,
7
)
expect(
electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
leaseToken: siblingBrowserTabId
})
).toBeUndefined()
expect(setViewport).toHaveBeenLastCalledWith(
undefined,
undefined,
undefined,
siblingBrowserTabId,
7
)
navigate.mockImplementationOnce(
async (...args: unknown[]) =>
new Promise<never>((_resolve, reject) => {
const signal = args[2] as AbortSignal
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
)
const abandonedNavigation = electronMocks.handlers.get(
ipcChannels.browserNavigate
)?.(event, {
conversationId: 'browser-conversation',
url: 'https://example.com/abandoned'
}) as Promise<void>
await vi.waitFor(() => expect(webContents.listenerCount('destroyed')).toBe(1))
webContents.emit('destroyed')
await expect(abandonedNavigation).rejects.toThrow('所属窗口已关闭')
navigate.mockRejectedValueOnce(new BrowserNavigationStoppedError())
await expect(
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
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 unsubscribe = vi.fn()
const onChanged = vi.fn<(listener: (settings: unknown) => void) => () => void>(() => unsubscribe)
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, onChanged } as never,
versionChecker as never
)
const changedSettings = { updateSource: 'github' }
onChanged.mock.calls[0]![0](changedSettings)
expect(webContents.send).toHaveBeenCalledWith(ipcChannels.applicationSettingsChanged, changedSettings)
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()
expect(unsubscribe).toHaveBeenCalledOnce()
})
})
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 = {
onChanged: vi.fn(),
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', () => ({
nativeImage: { createFromBuffer: (buffer: Buffer) => ({ isEmpty: () => false, toPNG: () => buffer }) },
app: {
getAppPath: vi.fn(() => process.cwd()),
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', () => ({
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.29',
architecture: 'x64'
},
installed: {
version: '1.18.29',
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.29',
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 = {
getAllSnapshot: vi.fn(async () => agentPackageInventory),
forPlatform: vi.fn(() => agentPackageManager),
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 })),
onChanged: vi.fn()
} 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.getAllSnapshot
).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',
platform: 'linux',
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.getAllSnapshot
).toHaveBeenCalledTimes(3)
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 remote-project 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 = {
onChanged: vi.fn(),
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 = {
database: { externalStore: { listBindings: () => [] } },
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',
expect.any(String)
)
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,
{
onChanged: vi.fn(),
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,
{
onChanged: vi.fn(),
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
} as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
for (const [channel, input] of [
[ipcChannels.workspaceChangesGet, projectId],
[ipcChannels.workspaceFileDiff, { projectId, path: 'deleted.txt' }],
[
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('validates execution statistics scope and sender before reading evidence', async () => {
const summary = { durationMs: 5000, requestCount: 2, incompleteRequestCount: 1, activeRequestCount: 0, asOf: 10000, taskDurations: [{ id: 'task', durationMs: 5000, incompleteRequestCount: 1 }] }
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getExecutionStatsAsync: vi.fn(async () => 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 () => {})
)
try {
const handler = electronMocks.handlers.get(ipcChannels.tasksExecutionStats)!
const event = { sender: webContents, senderFrame: webContents.mainFrame }
const id = '00000000-0000-4000-8000-000000000301'
for (const scope of [{ conversationId: id }, { projectId: id }]) {
await expect(handler(event, scope)).resolves.toEqual(summary)
expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenLastCalledWith(scope, new Set(), expect.stringContaining('execution-stats-worker.js'))
}
expect(() => handler(event, {})).toThrow()
expect(() => handler(event, { projectId: id, conversationId: id })).toThrow()
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow()
expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenCalledTimes(2)
} finally {
await dispose()
}
})
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',
runtimeSelection: undefined as { provider: 'opencode' } | undefined
})),
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).not.toHaveBeenCalled()
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
settingsStore.getResolvedSettings.mockResolvedValue({
provider: 'model',
modelProfiles: [],
opencodeBaseUrl: '',
workspacePath: 'C:\\DefaultWorkspace'
})
assistantDatabase.getProject.mockReturnValue({
id: projectId,
rootPath: 'C:\\ProjectWorkspace',
runtimeSelection: { provider: 'opencode' }
})
await expect(
electronMocks.handlers.get(
ipcChannels.agentCompactConversation
)?.(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>,
browserControl?: Record<string, unknown>,
imageService?: ImageGenerationService,
imageDatabase?: AssistantDatabase
) {
const assistantDatabase = {
createTask: vi.fn(),
appendTaskEvent: vi.fn(),
appendRemoteTaskEventOnce: vi.fn<
(input: RemoteTaskEventMockInput) => boolean
>(() => true),
appendRemoteConversationTaskEventOnce: vi.fn<AssistantDatabase['appendRemoteConversationTaskEventOnce']>(() => true),
listConversations: vi.fn<() => ConversationSnapshot[]>(() => []),
listConversationSummaries: vi.fn<(ids?: string[]) => ConversationSnapshot[]>(() => []),
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<AssistantDatabase['getHighestCommittedRemoteTaskEventSequenceForTask']>(
() => '0'
),
hasRemoteResponseTextAfterToolFailure: vi.fn<AssistantDatabase['hasRemoteResponseTextAfterToolFailure']>(() => false),
getRemoteTaskActivityStates: vi.fn<AssistantDatabase['getRemoteTaskActivityStates']>(() => ({ tools: new Map(), subagents: new Map() })),
endRecoverableRemoteTask: vi.fn(),
recordRemoteTaskQuestionArrival: vi.fn<AssistantDatabase['recordRemoteTaskQuestionArrival']>(),
recordRemoteTaskQuestionAnswer: vi.fn<AssistantDatabase['recordRemoteTaskQuestionAnswer']>(),
getTask: vi.fn<AssistantDatabase['getTask']>(),
updateTaskStatus: vi.fn(),
createTextArtifact: vi.fn(),
createImageArtifact: vi.fn(() => ({
id: '00000000-0000-4000-8000-000000000499',
title: '生成图片'
})),
getArtifact: vi.fn(),
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(),
completeTaskScheduleRun: 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 = {
id: 9,
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(() => '[]'),
hasImageInputs: vi.fn(() => false),
validateForSend: 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 args: Parameters<typeof 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
),
captureRollback: vi.fn(async () => ({
publicSettings: defaultRuntimeSettings,
restore: async () => defaultRuntimeSettings
})),
update: vi.fn(async (settings: unknown) => settings)
} as never,
(capabilityServiceOverride ?? {
setWebSearchEnabled: vi.fn(async () => ({}))
}) as never,
contextManager as never,
(knowledgeServiceOverride ?? {
database: { listKnowledgeBases: vi.fn(() => []) }
}) as never,
(imageDatabase ?? assistantDatabase) as never,
approvalBroker as never,
{} as never,
onRuntimeSettingsChanged,
onBeforeClearLocalData,
browserControl as never,
subagentService as never,
undefined,
{
get: getApplicationSettings,
onChanged: vi.fn()
} as never,
undefined,
undefined,
undefined,
selectedRuntimes as never,
undefined,
knowledgeGateway as never,
undefined,
undefined,
undefined,
undefined,
undefined,
goodbuddyConfigService as never
]
args[43] = imageService
const dispose = registerIpcHandlers(...args)
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('binds the production image service before text dispatch and routes regenerate, Ask rejection, and deletion through IPC', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const conversationId = crypto.randomUUID()
const userId = crypto.randomUUID()
const messageId = crypto.randomUUID()
const header = { id: conversationId, title: 'Image IPC', updatedAt: Date.now(), workMode: 'execute' as const }
database.saveLocalConversations([{ header, messages: [] }])
const profile = { id: crypto.randomUUID(), name: 'Image model', modelName: 'image-test', protocol: 'openai-images-generations',
baseUrl: 'https://image.test/v1', authentication: 'none' as const, allowConversationInvocation: true }
const fetcher = vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: 'iVBORw0KGgo=' }] }))
const service = new ImageGenerationService({ database, getSettings: async () => ({ modelProfiles: [profile], defaultImageModelProfileId: profile.id }), fetcher })
service.initialize()
const run = vi.fn(async function* (request: AgentExecutionRequest) {
expect(database.getConversation(conversationId).messages.map(message => message.id)).toEqual([userId, messageId])
expect(request.imageToolBinding?.context.messageId).toBe(messageId)
expect(await request.imageToolBinding?.describe()).toContain(profile.id)
expect((await request.imageToolBinding!.call({ intent: 'create', prompt: 'A blue circle' }, 'image-call')).state).toBe('completed')
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness({ runtimeId: 'model', capability: 'text', supportsToolExecution: true, run },
undefined, 'always', undefined, false, undefined, undefined, undefined, false, undefined, undefined, undefined, service, database)
const event = trustedEvent(harness.webContents)
try {
const requestId = crypto.randomUUID()
await harness.handler!(event, { requestId, conversationId, prompt: 'Draw a circle', workMode: 'execute',
currentUserMessageId: userId, currentAssistantMessageId: messageId })
await vi.waitFor(() => expect(database.getTask(requestId).status).toBe('completed'))
const original = database.getConversation(conversationId).messages[1]!.imageOperations![0]!
expect(database.getArtifact(original.artifactIds[0]!).content).toContain('iVBORw0KGgo=')
const regenerate = electronMocks.handlers.get(ipcChannels.imageOperationRegenerate)!
const cancel = electronMocks.handlers.get(ipcChannels.imageOperationCancel)!
expect(() => cancel(event, { conversationId, operationId: original.id, prompt: 'injected' })).toThrow()
const regenerated = await regenerate(event, { conversationId, operationId: original.id }) as typeof original
expect(regenerated.id).not.toBe(original.id)
expect(regenerated.messageId).toBe(messageId)
expect(fetcher).toHaveBeenCalledTimes(2)
database.saveLocalConversations([{ header: { ...header, workMode: 'ask' }, messages: [] }])
await expect(regenerate(event, { conversationId, operationId: original.id })).rejects.toThrow('Execute')
expect(fetcher).toHaveBeenCalledTimes(2)
const cancelConversation = vi.spyOn(service, 'cancelConversation')
await electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)
expect(cancelConversation).toHaveBeenCalledWith(conversationId)
expect(() => database.getConversation(conversationId)).toThrow()
} finally {
await harness.dispose()
await service.dispose()
database.close()
}
})
it('persists uploads before dispatch while withholding image bytes from a nonvisual text model', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const conversationId = crypto.randomUUID()
const userId = crypto.randomUUID()
database.saveLocalConversations([{ header: { id: conversationId, title: 'Upload', updatedAt: Date.now() }, messages: [] }])
const service = new ImageGenerationService({ database, getSettings: async () => ({ modelProfiles: [] }) })
const run = vi.fn(async function* (request: AgentExecutionRequest) {
expect(request.images).toBeUndefined()
expect(request.trustedInstructions).toContain('cannot view')
const sources = database.getConversation(conversationId).messages.find(message => message.id === userId)!.imageSourceArtifactIds!
expect(sources).toHaveLength(1)
expect(database.getArtifact(sources[0]!).content).toContain('iVBORw0KGgo=')
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness({ runtimeId: 'model', capability: 'text', supportsToolExecution: true, run },
undefined, 'always', undefined, false, undefined, undefined, undefined, false, undefined, undefined, undefined, service, database)
harness.contextManager.enrichRequest.mockImplementation(request => ({ ...request, images: [{ name: 'Upload', mediaType: 'image/png', data: 'iVBORw0KGgo=' }] }))
try {
const requestId = crypto.randomUUID()
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId, prompt: 'Turn it red', workMode: 'execute',
currentUserMessageId: userId, currentAssistantMessageId: crypto.randomUUID() })
await vi.waitFor(() => expect(database.getTask(requestId).status).toBe('completed'))
expect(run).toHaveBeenCalledOnce()
} finally { await harness.dispose(); await service.dispose(); database.close() }
})
it('restores image context at the IPC boundary and persists the quiet notice on its artifact event', async () => {
const png = 'iVBORw0KGgo='
const artifactId = '00000000-0000-4000-8000-000000000301'
const run = vi.fn(async function* (request: {
requestId: string
images?: Array<{ data: string }>
}) {
expect(request.images?.[0]?.data).toBe(png)
yield {
requestId: request.requestId, type: 'generated-image', mimeType: 'image/png',
data: png, title: '编辑图片', imageContextNotice: 'editing-unavailable'
}
yield { requestId: request.requestId, type: 'done' }
})
const harness = createHarness({
runtimeId: 'model', capability: 'image-generation',
supportsToolExecution: false, run
})
harness.assistantDatabase.getArtifact.mockReturnValue({
id: artifactId, kind: 'image', content: `data:image/png;base64,${png}`
})
try {
await harness.handler!(trustedEvent(harness.webContents), {
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
prompt: '把圆改成蓝色', imageContextArtifactIds: [artifactId],
workMode: 'ask'
})
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus)
.toHaveBeenCalledWith(expect.any(String), 'completed'))
expect(harness.assistantDatabase.getArtifact).toHaveBeenCalledWith(artifactId)
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({
type: 'artifact', imageContextNotice: 'editing-unavailable'
})
)
} finally {
await harness.dispose()
}
})
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: new Date(Date.now() + 60_000).toISOString()
}
await electronMocks.handlers.get(
ipcChannels.schedulesCreate
)?.(trustedEvent(harness.webContents), createInput)
expect(
harness.assistantDatabase.createSchedule
).toHaveBeenCalledWith({
...createInput,
workMode: 'execute'
})
await harness.dispose()
})
it.each(['global', 'projects'] as const)('collects supervision within the exact UI timeRange before limits (%s)', async (kind) => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-09-22T08:00:00.000Z'))
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const project = database.createProject({ name: 'Review', description: '', rootPath: process.cwd(), defaultWorkMode: 'ask' })
const conversationId = crypto.randomUUID()
const from = '2026-09-22T10:00:00.000Z'
const to = '2026-09-22T10:15:00.000Z'
const after = '2026-09-22T10:15:00.001Z'
const oldTask = database.createTask({ id: crypto.randomUUID(), projectId: project.id, title: 'Old completed task', instructions: 'Review', workMode: 'ask' })
const memory = database.createMemory({ scope: 'global', type: 'fact', content: 'Current background content' })
vi.setSystemTime(new Date(from))
const newTask = database.createTask({ id: crypto.randomUUID(), projectId: project.id, title: 'Created in range', instructions: 'Review', workMode: 'ask' })
vi.setSystemTime(new Date(to))
database.updateTaskStatus(oldTask.id, 'completed')
vi.setSystemTime(new Date(after))
database.updateTaskStatus(newTask.id, 'completed')
database.createTask({ id: crypto.randomUUID(), projectId: project.id, title: 'Future task', instructions: 'Review', workMode: 'ask' })
database.setMemoryStatus(memory.id, 'confirmed')
const message = (content: string, timestamp: string) => ({ id: crypto.randomUUID(), role: 'user' as const, state: 'complete' as const, content, createdAt: Date.parse(timestamp) })
database.saveLocalConversations([{
header: { id: conversationId, projectId: project.id, title: 'Review', updatedAt: Date.parse(after) },
messages: [message('Before range', '2026-09-22T09:59:59.999Z'), message('At start', from), message('At end', to),
...Array.from({ length: 25 }, () => message('After range', after))]
}, ...Array.from({ length: 21 }, () => ({
header: { id: crypto.randomUUID(), projectId: project.id, title: 'Future-only conversation', updatedAt: Date.parse(after) + 1 },
messages: [message('Future-only message', after)]
}))])
const run = vi.fn(async function* (request: AgentExecutionRequest) {
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify({ summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
yield { type: 'done' as const, requestId: request.requestId }
})
vi.setSystemTime(new Date('2026-09-23T12:00:00.000Z'))
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run }, undefined, 'always', undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database)
try {
const scope = kind === 'global' ? { kind } : { kind, projectIds: [project.id] }
const input = { trigger: 'manual', scope, timeRange: { from, to } }
const result = await electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), input) as import('./assistant/supervisor-service').StoredSupervisionResult
expect(result.request).toEqual(input)
expect(result.evidence.filter(item => item.sourceType === 'conversation').map(item => item.content)).toEqual(['At start', 'At end'])
expect(result.evidence.filter(item => item.sourceType === 'task')).toEqual(expect.arrayContaining([
expect.objectContaining({ sourceId: oldTask.id, occurredAt: to }),
expect.objectContaining({ sourceId: newTask.id, occurredAt: from })
]))
expect(result.evidence.filter(item => item.sourceType === 'task')).toHaveLength(2)
expect(result.evidence.find(item => item.sourceType === 'memory')).toMatchObject({
content: memory.content, occurredAt: after,
locator: { temporalRole: 'current-background', createdAt: memory.createdAt, updatedAt: after }
})
expect(run.mock.calls[0]![0].prompt).toContain(JSON.stringify(input.timeRange))
expect(run.mock.calls[0]![0].prompt).toContain('not a historical content snapshot')
const overview = await electronMocks.handlers.get(ipcChannels.supervisionOverview)!(trustedEvent(harness.webContents), { target: { type: 'conversation', conversationId } }) as Array<{ id: string; storyLineId: string; sourceId: string }>
expect(overview).toHaveLength(1)
expect(database.getSupervisionSource(overview[0]!.sourceId)?.sourceId).toBe(conversationId)
const graph = await electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: overview[0]!.storyLineId }) as { sources: unknown[] }
expect(graph.sources).toHaveLength(result.evidence.length)
expect(() => electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).toThrow('不匹配')
expect(() => electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!(trustedEvent(harness.webContents), { resultId: 'wrong-result', sourceId: overview[0]!.sourceId })).toThrow('不匹配')
const legacy = database.buildHeartbeatInput({ scope, lookbackHours: 1 }, new Date(to))
expect(legacy.tasks.some(task => task.id === oldTask.id)).toBe(false)
expect(legacy.tasks.some(task => task.title === 'Future task')).toBe(true)
database.setProjectArchived(project.id, true)
await expect(electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), {
...input, scope: { kind: 'projects', projectIds: [project.id] }
})).rejects.toThrow('must exist and be active')
expect(run).toHaveBeenCalledOnce()
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
})
it('dispatches supervision continuation through the real queue payload parser', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const conversationId = crypto.randomUUID()
database.saveLocalConversations([{ header: { id: conversationId, title: 'Continue', updatedAt: Date.now(), workMode: 'ask', runtimeSelection: { provider: 'opencode' } }, messages: [] }])
const harness = createHarness({ capability: 'chat' }, undefined, 'always', undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database)
try {
await electronMocks.handlers.get(ipcChannels.supervisionContinue)!(trustedEvent(harness.webContents), { conversationId, prompt: 'Continue this review' })
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.conversationQueueDispatch,
expect.objectContaining({ input: expect.objectContaining({ conversationId, prompt: 'Continue this review', attachments: [] }) })))
const dispatch = harness.webContents.send.mock.calls.find(([channel]) => channel === ipcChannels.conversationQueueDispatch)![1] as ConversationQueueDispatch
expect(database.isConversationUserQueueItemDispatching(dispatch.item.id)).toBe(true)
expect(harness.contextManager.restoreFromQueue).not.toHaveBeenCalled()
} finally { await harness.dispose(); database.close() }
})
it('validates supervision knowledge preview and commit against actual local entity ownership', async () => {
const database = new KnowledgeDatabase(':memory:')
database.initialize()
const library = database.createKnowledgeBase({ name: 'Target', storageMode: 'reference' })
const other = database.createKnowledgeBase({ name: 'Other', storageMode: 'reference' })
const entity = database.createEntity({ knowledgeBaseId: other.id, name: 'Original', type: 'Concept' })
const harness = createHarness({ capability: 'chat' }, undefined, 'always', undefined, false, undefined, { database })
Object.assign(harness.assistantDatabase, { getSupervisionSource: () => ({ id: 'source', title: 'Source', content: 'Evidence' }) })
const event = trustedEvent(harness.webContents)
const preview = electronMocks.handlers.get(ipcChannels.supervisionKnowledgePreview)!
const commit = electronMocks.handlers.get(ipcChannels.supervisionKnowledgeCommit)!
const input = { operation: 'update-entity', libraryId: library.id, entityId: entity.id, label: 'Revised', type: 'Concept', description: 'Preview description', aliases: ['Alias'], sourceId: 'source' }
try {
expect(() => preview(event, input)).toThrow('不属于所选知识库')
expect(() => preview(event, { ...input, entityId: crypto.randomUUID() })).toThrow('不属于所选知识库')
expect(() => preview(event, { ...input, operation: 'create-entity', libraryId: crypto.randomUUID() })).toThrow('知识库不存在')
const binding = vi.spyOn(database.externalStore, 'hasBinding').mockReturnValue(true)
expect(() => preview(event, { ...input, operation: 'create-entity' })).toThrow('EXTERNAL_KB_READ_ONLY')
binding.mockReturnValue(false)
const valid = { ...input, libraryId: other.id }
const first = await preview(event, valid) as { previewId: string }
expect(first).toMatchObject({ entity: valid, source: { content: 'Evidence' } })
binding.mockReturnValue(true)
expect(() => commit(event, { previewId: first.previewId })).toThrow('EXTERNAL_KB_READ_ONLY')
expect(database.getEntity(entity.id)?.name).toBe('Original')
binding.mockReturnValue(false)
const second = await preview(event, valid) as { previewId: string }
const getEntity = vi.spyOn(database, 'getEntity').mockReturnValue({ ...entity, knowledgeBaseId: library.id })
expect(() => commit(event, { previewId: second.previewId })).toThrow('不属于所选知识库')
getEntity.mockRestore()
const third = await preview(event, valid) as { previewId: string }
await commit(event, { previewId: third.previewId })
expect(database.getEntity(entity.id)).toMatchObject({ name: 'Revised', description: 'Preview description', locked: true })
const create = await preview(event, { ...input, operation: 'create-entity', entityId: undefined }) as { previewId: string }
await commit(event, { previewId: create.previewId })
expect(database.listEntities(library.id)).toEqual([expect.objectContaining({ name: 'Revised', locked: true })])
} finally { vi.restoreAllMocks(); await harness.dispose(); database.close() }
})
it.each(['completed', 'failed'] as const)('creates an immediate task into the production queue and dispatches exactly one ordinary run (%s)', async (status) => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const conversationId = crypto.randomUUID()
database.saveLocalConversations([{
header: { id: conversationId, title: 'Immediate', updatedAt: Date.now(), workMode: 'ask' },
messages: []
}])
const run = vi.fn(async function* (request: AgentExecutionRequest) {
if (status === 'failed') {
yield { type: 'error' as const, requestId: request.requestId, message: 'Runtime failed' }
return
}
yield { type: 'text' as const, requestId: request.requestId, delta: 'Once' }
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness(
{ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run },
undefined, 'always', undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database
)
const event = trustedEvent(harness.webContents)
try {
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
const create = electronMocks.handlers.get(ipcChannels.schedulesCreate)!
const input = {
conversationId, title: 'Immediate', prompt: 'Run once', recurrence: 'once',
nextRunAt: '2020-01-01T00:00:00.000Z'
}
expect(() => create(event, input)).toThrow('首次运行时间必须晚于当前时间')
expect(database.listSchedules()).toEqual([])
const schedule = await create(event, { ...input, runImmediately: true }) as AssistantSchedule
expect(schedule.enabled).toBe(false)
const dispatches = () => harness.webContents.send.mock.calls.filter(
([channel]) => channel === ipcChannels.conversationQueueDispatch
)
expect(dispatches()).toHaveLength(1)
const dispatch = dispatches()[0]![1] as ConversationQueueDispatch
expect(dispatch).toMatchObject({ scheduled: true, item: { taskId: schedule.taskId } })
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
await harness.handler!(event, {
requestId: dispatch.item.id,
conversationId,
queueItemId: dispatch.item.id,
prompt: input.prompt,
workMode: 'ask',
currentUserMessageId: crypto.randomUUID(),
currentAssistantMessageId: crypto.randomUUID()
})
await vi.waitFor(() => expect(database.getTask(schedule.taskId).status).toBe(status))
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
expect(dispatches()).toHaveLength(1)
expect(run).toHaveBeenCalledOnce()
expect(database.listConversationQueueItems()).toEqual([])
expect(database.listSchedules()).toHaveLength(1)
} finally {
await harness.dispose()
database.close()
}
})
it('dispatches scheduled messages through ordinary agent runs with current history and tools', async () => {
const fullOutput = `${'x'.repeat(1_000_001)} scheduled tail`
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; history?: unknown; workMode?: string },
_signal: AbortSignal,
authorize: (
input: {
scopeKey: string
title: string
description: string
}
) => Promise<string>
) {
expect(request.requestId).toBe(runId)
expect(request.history).toEqual([{ role: 'user', content: 'Earlier conversation' }])
expect(request.workMode).toBe('execute')
expect(await authorize({
scopeKey: 'workspace.write',
title: '写入工作区',
description: '更新状态文件'
})).toBe('once')
yield {
requestId: request.requestId,
type: 'text',
delta: fullOutput
} as const
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)?.(
trustedEvent(harness.webContents), conversationId)
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.getConversationQueueItem.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)
expect(harness.assistantDatabase.createTask).not.toHaveBeenCalled()
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item: queueItem, scheduled: true, input: {
conversationId, projectId: schedule.projectId, prompt: schedule.prompt
} })
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: runId, conversationId, queueItemId: queueItem.id,
prompt: schedule.prompt, workMode: 'execute',
history: [{ role: 'user', content: 'Earlier conversation' }]
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.completeTaskScheduleRun
).toHaveBeenCalledWith(runId)
)
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(taskId, 'running')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(
harness.assistantDatabase.appendConversationMessage
).not.toHaveBeenCalled()
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent, expect.objectContaining({
requestId: runId, type: 'text', delta: fullOutput
}))
expect(
harness.assistantDatabase.createTextArtifact
).not.toHaveBeenCalled()
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationsChanged
)
await harness.dispose()
})
it('allows scheduled messages to ask questions through the ordinary conversation UI', 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
yield { requestId: request.requestId, type: 'done' } as const
}
}
const harness = createHarness(runtime)
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)?.(
trustedEvent(harness.webContents), conversationId)
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.getConversationQueueItem.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 harness.handler?.(trustedEvent(harness.webContents), {
requestId: runId, conversationId, queueItemId: queueItem.id,
prompt: schedule.prompt, workMode: 'execute'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.completeTaskScheduleRun
).toHaveBeenCalledWith(runId)
)
expect(respondToQuestion).not.toHaveBeenCalled()
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent, expect.objectContaining({
type: 'question', questionId: 'opencode-background-question'
}))
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 the active lease through settings reload and releases it only after cancellation settles', 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(false)
await expect(harness.handler?.(event, {
requestId,
conversationId: newConversationId,
prompt: '替换请求',
workMode: 'ask',
knowledgeLibraryIds: []
})).rejects.toThrow('请求正在执行')
harness.cancelHandler?.(event, requestId)
expect(controls.get(oldConversationId)?.signal.aborted).toBe(true)
controls.get(oldConversationId)?.finish()
await vi.waitFor(() =>
expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce()
)
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
await harness.handler?.(event, {
requestId,
conversationId: newConversationId,
prompt: '替换请求',
workMode: 'ask',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(controls.has(newConversationId)).toBe(true)
)
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('cancels only an expert-team parent on settings replacement without synthesizing on the replacement model', async () => {
let rejectExpert!: (error: Error) => void
let parentSignal: AbortSignal | undefined
const subagents = {
run: vi.fn(async (input: { expert: { name: string }; signal: AbortSignal }) => {
parentSignal = input.signal
if (input.expert.name === 'First') return { childTaskId: 'first', output: 'Finished analysis' }
return new Promise((_, reject) => { rejectExpert = reject })
}),
synthesize: vi.fn(async () => 'Must not use replacement model'),
dispose: vi.fn(async () => undefined)
}
let finishPeer!: () => void
let peerSignal: AbortSignal | undefined
const runtime = {
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
async *run(request: { requestId: string }, signal: AbortSignal) {
peerSignal = signal
await new Promise<void>(resolve => { finishPeer = resolve })
yield { requestId: request.requestId, type: 'done' as const }
}
}
const harness = createHarness(runtime, undefined, 'always', subagents)
harness.assistantDatabase.listExperts.mockReturnValue([
{ id: '00000000-0000-4000-8000-000000000781', name: 'First', enabled: true },
{ id: '00000000-0000-4000-8000-000000000782', name: 'Second', enabled: true }
])
harness.onRuntimeSettingsChanged.mockImplementation(async () => {
rejectExpert(new Error('默认模型设置已更改'))
})
const event = trustedEvent(harness.webContents)
const teamId = '00000000-0000-4000-8000-000000000783'
try {
await harness.handler?.(event, {
requestId: teamId, conversationId: 'expert-team',
prompt: 'Analyze with the team', teamMode: true, workMode: 'ask', knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(subagents.run).toHaveBeenCalledTimes(2))
await harness.handler?.(event, {
requestId: '00000000-0000-4000-8000-000000000784', conversationId: 'ordinary-peer',
prompt: 'Keep working', workMode: 'ask', knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(peerSignal).toBeDefined())
await electronMocks.handlers.get(ipcChannels.runtimeCustomizationUpdate)!(event, {
opencode: {}, continue: { presets: [] }
})
expect(parentSignal?.aborted).toBe(true)
expect(peerSignal?.aborted).toBe(false)
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus)
.toHaveBeenCalledWith(teamId, 'cancelled', '请求已取消'))
expect(subagents.synthesize).not.toHaveBeenCalled()
} finally {
rejectExpert?.(new Error('Test cleanup'))
finishPeer?.()
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('persists remote usage with the inherited project Runtime without pinning the conversation', async () => {
const requestId = '00000000-0000-4000-8000-000000000790'
const conversationId = 'inherited-remote-usage'
const selection = { provider: 'opencode', profileId: '00000000-0000-4000-8000-000000000791' } as const
const provenance = {
source: 'remote-semantic-transcript',
bindingId: 'inherited-usage',
operationId: requestId,
semanticSequence: '1',
eventIndex: 0
} as const
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId, type: 'context-metrics',
contextTokens: 42, effectiveTriggerTokens: 1000,
compressionEnabled: false, source: 'provider',
remoteProvenance: provenance
} as const
yield {
requestId: request.requestId, type: 'done',
remoteProvenance: { ...provenance, semanticSequence: '2' }
} as const
}
}
const { harness, projectId } = createManagedSshHarness(selectedRuntime)
const project = harness.assistantDatabase.getProject(projectId)
harness.assistantDatabase.getProject.mockReturnValue({ ...project, runtimeSelection: selection })
harness.assistantDatabase.getConversation.mockReturnValue({
id: conversationId, projectId, messages: []
})
await harness.handler?.(trustedEvent(harness.webContents), {
...managedSshRequest(projectId, requestId, conversationId),
runtimeSelection: undefined,
currentUserMessageId: '00000000-0000-4000-8000-000000000792',
currentAssistantMessageId: '00000000-0000-4000-8000-000000000793'
})
await vi.waitFor(() => expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledWith(
expect.objectContaining({ event: expect.objectContaining({ type: 'done' }) })
))
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledWith(
expect.objectContaining({
conversationId,
runtimeSelection: selection,
event: expect.objectContaining({ type: 'context-metrics', contextTokens: 42 })
})
)
expect(harness.assistantDatabase.getConversation(conversationId)).not.toHaveProperty('runtimeSelection')
await harness.dispose()
})
it('advertises read-only workspace and delegation tools in the production Ask instruction', async () => {
const received: string[] = []
const runtime = {
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
async *run(request: { requestId: string; trustedInstructions?: string }) {
received.push(request.trustedInstructions ?? '')
yield { type: 'done', requestId: request.requestId } as const
}
}
const harness = createHarness(runtime)
const requestId = '00000000-0000-4000-8000-000000000794'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId, conversationId: 'ask-workspace', prompt: 'Read the source', workMode: 'ask'
})
await vi.waitFor(() => expect(received).toHaveLength(1))
for (const name of ['workspace_rg', 'workspace_read_text', 'output_read', 'subagent_delegate']) {
expect(received[0]).toContain(name)
}
expect(received[0]).not.toContain('workspace_apply_patch')
expect(received[0]).not.toContain('process_execute')
await harness.dispose()
})
it('durably commits remote text before resume, deduplicates it, and preserves its semantic payload', async () => {
const requestId = '00000000-0000-4000-8000-000000000763'
const provenance = {
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.each([false, true])('reattaches from its committed cursor with tool recovery evidence %s', async (hasRecoveredResponse) => {
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',
remoteHasResponseTextAfterToolFailure: hasRecoveredResponse,
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
if (hasRecoveredResponse) yield {
requestId: taskId, type: 'tool', callId: 'tool-before-restart',
name: 'Read', state: 'recoverable', summary: 'Handled missing file', error: 'missing file',
remoteProvenance: {
source: 'remote-semantic-transcript', bindingId: 'binding-recovery',
operationId: taskId, semanticSequence: '4', eventIndex: 0
}
} as const
yield {
requestId: taskId,
type: 'done',
sessionId: 'session-recovery',
remoteProvenance: {
source: 'remote-semantic-transcript',
bindingId: 'binding-recovery',
operationId: taskId,
semanticSequence: '4',
eventIndex: hasRecoveredResponse ? 1 : 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: hasRecoveredResponse ? 2 : 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.assistantDatabase.hasRemoteResponseTextAfterToolFailure.mockReturnValue(hasRecoveredResponse)
harness.recoveryGetHandler?.(
trustedEvent(harness.webContents)
)
await vi.waitFor(() => expect(consumed).toEqual(['3', '4']))
await vi.waitFor(() =>
expect(
harness.assistantDatabase.appendRemoteConversationTaskEventOnce
).toHaveBeenCalledTimes(hasRecoveredResponse ? 5 : 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(
hasRecoveredResponse ? 4 : 3,
expect.objectContaining({
taskId,
semanticSequence: '4',
event: hasRecoveredResponse ? {
requestId: taskId, type: 'done', sessionId: 'session-recovery'
} : {
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('replays an error committed before its checkpoint using remote tool states instead of terminal message states', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize('C:\\Workspace')
const project = database.createSshProject({
project: { name: 'Partial terminal', description: '', rootPath: '/srv/project', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } },
executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000850', remoteRootPath: '/srv/project' },
assertCurrent: () => {}
})
const taskId = '00000000-0000-4000-8000-000000000851'
const conversationId = '00000000-0000-4000-8000-000000000852'
const assistantMessageId = '00000000-0000-4000-8000-000000000853'
database.saveLocalConversations([{
header: { id: conversationId, projectId: project.id, title: 'Partial terminal', updatedAt: 0 }, messages: []
}])
database.createTask({
id: taskId, projectId: project.id, conversationId, title: 'Partial terminal', instructions: 'Read', workMode: 'execute',
remoteRecovery: { recoverable: true, currentUserMessageId: '00000000-0000-4000-8000-000000000854', currentAssistantMessageId: assistantMessageId }
})
const append = (semanticSequence: string, eventIndex: number, event: Parameters<AssistantDatabase['appendRemoteConversationTaskEventOnce']>[0]['event']) =>
database.appendRemoteConversationTaskEventOnce({
taskId, conversationId, assistantMessageId, bindingId: 'partial-terminal', operationId: taskId,
semanticSequence, eventIndex, event
})
const events: AgentEvent[] = [
{ requestId: taskId, type: 'tool', callId: 'A', name: 'Read', state: 'failed', summary: 'Read', error: 'missing file' },
{ requestId: taskId, type: 'text', delta: 'Handled missing file' },
{ requestId: taskId, type: 'tool', callId: 'B', name: 'Write', state: 'running', summary: 'Write', input: 'preserve input' }
]
events.forEach((event, index) => {
append(String(index + 1), 0, event)
append(String(index + 1), 1, { requestId: taskId, type: 'remote-semantic-checkpoint' })
})
const error: AgentEvent = { requestId: taskId, type: 'error', status: 'failed', message: 'Read 工具执行失败:missing file' }
append('4', 0, error)
expect(database.getConversation(conversationId).messages[1]?.tools?.map(tool => tool.state)).toEqual(['failed', 'failed'])
expect(database.getTask(taskId).status).toBe('running')
const recoveredRuntime = {
runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true,
async *run(request: AgentExecutionRequest) {
expect(request).toMatchObject({
requestId: taskId, remoteRecoveryOnly: true, remoteSemanticAfterSequence: '3',
remoteHasResponseTextAfterToolFailure: true,
remoteRecoveredTools: [
expect.objectContaining({ callId: 'A', state: 'failed', error: 'missing file' }),
expect.objectContaining({ callId: 'B', state: 'running', input: 'preserve input' })
]
})
const remoteProvenance = {
source: 'remote-semantic-transcript' as const, bindingId: 'partial-terminal',
operationId: taskId, semanticSequence: '4', eventIndex: 0
}
yield { requestId: taskId, type: 'done' as const, remoteProvenance }
yield { requestId: taskId, type: 'remote-semantic-checkpoint' as const, remoteProvenance: { ...remoteProvenance, eventIndex: 1 } }
}
}
const { harness } = createManagedSshHarness(recoveredRuntime)
harness.assistantDatabase.getProject.mockImplementation(database.getProject.bind(database))
harness.assistantDatabase.getConversation.mockImplementation(database.getConversation.bind(database))
harness.assistantDatabase.listRecoverableRemoteTasks.mockImplementation(database.listRecoverableRemoteTasks.bind(database))
harness.assistantDatabase.getHighestCommittedRemoteTaskEventSequenceForTask.mockImplementation(database.getHighestCommittedRemoteTaskEventSequenceForTask.bind(database))
harness.assistantDatabase.hasRemoteResponseTextAfterToolFailure.mockImplementation(database.hasRemoteResponseTextAfterToolFailure.bind(database))
harness.assistantDatabase.getRemoteTaskActivityStates.mockImplementation(database.getRemoteTaskActivityStates.bind(database))
harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database))
try {
harness.recoveryGetHandler?.(trustedEvent(harness.webContents))
await vi.waitFor(() => expect(database.getTask(taskId).status).toBe('failed'))
expect(database.getHighestCommittedRemoteTaskEventSequenceForTask(taskId)).toBe('4')
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenNthCalledWith(1,
expect.objectContaining({ semanticSequence: '4', eventIndex: 0, event: error }))
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mock.results.map(result => result.value)).toEqual([false, true])
expect(database.getRemoteTaskActivityStates(taskId).tools.get('B')).toBe('running')
} finally {
await harness.dispose()
database.close()
}
})
it.each(['runtime', 'customization', 'capabilities'] as const)('restores concurrent live questions and their answer history across %s settings reload', async (settingsKind) => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-recovered-question-history-'))
const databasePath = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(databasePath)
database.initialize(directory)
const project = database.createSshProject({
project: {
name: 'Recovered questions', description: '', rootPath: '/srv/project',
defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' }
},
executionSpace: {
kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000850',
remoteRootPath: '/srv/project'
},
assertCurrent: () => {}
})
const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
const tasks = [1, 2].map(n => ({
taskId: id(810 + n), conversationId: id(820 + n), projectId: project.id,
currentUserMessageId: id(830 + n), currentAssistantMessageId: id(840 + n),
instructions: 'Original question', workMode: 'execute' as const,
status: 'running' as const
}))
for (const task of tasks) {
database.saveLocalConversations([{
header: {
id: task.conversationId, projectId: project.id,
title: 'Recovered', updatedAt: Date.now(), runtimeSelection: { provider: 'opencode' }
},
messages: []
}])
database.createTask({
id: task.taskId, conversationId: task.conversationId, projectId: project.id,
title: 'Recovered question', instructions: task.instructions, workMode: task.workMode,
remoteRecovery: {
recoverable: true, currentUserMessageId: task.currentUserMessageId,
currentAssistantMessageId: task.currentAssistantMessageId
}
})
}
const ready = new Set<string>()
const signals: AbortSignal[] = []
const finish = new Map<string, () => void>()
const runtime = {
runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true,
respondToQuestion: vi.fn(async (questionId: string) => { finish.get(questionId)?.() }),
async *run(request: { requestId: string; remoteRecoveryOnly?: boolean }, signal: AbortSignal) {
signals.push(signal)
expect(request.remoteRecoveryOnly).toBe(true)
const answered = new Promise<void>(resolve => { finish.set(request.requestId, resolve) })
ready.add(request.requestId)
yield {
requestId: request.requestId, type: 'question' as const, questionId: request.requestId,
questions: [{ header: 'Input', question: 'Continue?', options: [], custom: true, multiple: false }]
}
await answered
yield {
requestId: request.requestId, type: 'done' as const, sessionId: request.requestId,
remoteProvenance: {
source: 'remote-semantic-transcript' as const, bindingId: request.requestId,
operationId: request.requestId, semanticSequence: '1', eventIndex: 0
}
}
yield {
requestId: request.requestId, type: 'remote-semantic-checkpoint' as const,
remoteProvenance: {
source: 'remote-semantic-transcript' as const, bindingId: request.requestId,
operationId: request.requestId, semanticSequence: '1', eventIndex: 1
}
}
}
}
const { harness } = createManagedSshHarness(runtime)
harness.assistantDatabase.getProject.mockReturnValue({
id: project.id, runtimeSelection: { provider: 'opencode' }, rootPath: '/srv/project',
executionSpace: { kind: 'ssh', hostId: id(850), remoteRootPath: '/srv/project' }
})
harness.assistantDatabase.getConversation.mockImplementation(database.getConversation.bind(database))
harness.assistantDatabase.listConversations.mockImplementation(database.listConversations.bind(database))
harness.assistantDatabase.listConversationSummaries.mockImplementation(database.listConversationSummaries.bind(database))
harness.assistantDatabase.listRecoverableRemoteTasks.mockImplementation(database.listRecoverableRemoteTasks.bind(database))
harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database))
harness.assistantDatabase.updateTaskStatus.mockImplementation(database.updateTaskStatus.bind(database))
harness.assistantDatabase.getTask.mockImplementation(database.getTask.bind(database))
harness.assistantDatabase.recordRemoteTaskQuestionArrival.mockImplementation(database.recordRemoteTaskQuestionArrival.bind(database))
harness.assistantDatabase.recordRemoteTaskQuestionAnswer.mockImplementation(
(...args) => database.recordRemoteTaskQuestionAnswer(...args)
)
const event = trustedEvent(harness.webContents)
const list = () => electronMocks.handlers.get(ipcChannels.conversationsList)!(event) as ConversationSnapshot[]
try {
harness.recoveryGetHandler?.(event)
await vi.waitFor(() => expect(ready.size).toBe(2))
await vi.waitFor(() => expect(list().every(c => c.activeRequest?.questions.length === 1)).toBe(true))
const summaries = electronMocks.handlers.get(ipcChannels.conversationsListSummaries)!(event, { detailIds: [] }) as ConversationSnapshot[]
expect(summaries).toEqual(list())
for (const snapshot of summaries) {
expect(electronMocks.handlers.get(ipcChannels.conversationsGet)!(event, snapshot.id)).toEqual(snapshot)
}
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled()
const update = settingsKind === 'runtime'
? [ipcChannels.runtimeSettingsUpdate, runtimeSettingsInputSchema.strip().parse({
...defaultRuntimeSettings, workspacePath: directory, apiKey: { action: 'keep' }
})] as const
: settingsKind === 'customization'
? [ipcChannels.runtimeCustomizationUpdate, { opencode: { defaultAgent: 'build' }, continue: { presets: [] } }] as const
: [ipcChannels.capabilitiesToggleWebSearch, false] as const
await electronMocks.handlers.get(update[0])!(event, update[1])
expect(signals.every(signal => !signal.aborted)).toBe(true)
expect(harness.approvalBroker.clear).not.toHaveBeenCalled()
for (const [index, task] of [...tasks].reverse().entries()) {
const snapshot = list().find(c => c.id === task.conversationId)!
expect(snapshot.activeRequest).toMatchObject({
requestId: task.taskId, messageId: task.currentAssistantMessageId
})
await electronMocks.handlers.get(ipcChannels.agentQuestionRespond)!(event, {
questionId: task.taskId, answers: index === 0 ? [['Yes']] : []
})
}
await vi.waitFor(() => expect(list().every(c => !c.activeRequest)).toBe(true))
expect(runtime.respondToQuestion.mock.calls.map(([questionId]) => questionId))
.toEqual([...tasks].reverse().map(task => task.taskId))
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledTimes(4)
database.close()
const reopened = new AssistantDatabase(databasePath)
reopened.initialize(directory)
try {
for (const [index, task] of tasks.entries()) {
expect(reopened.getTask(task.taskId).status).toBe('completed')
expect(reopened.getConversation(task.conversationId).messages[1]).toMatchObject({
id: task.currentAssistantMessageId, state: 'complete',
blocks: [expect.objectContaining({ type: 'question', questionId: task.taskId })],
answeredQuestions: [{
questionId: task.taskId, skipped: index === 0,
questions: [{
header: 'Input', question: 'Continue?', options: [], multiple: false, custom: true,
...(index === 1 ? { answer: ['Yes'] } : {})
}]
}]
})
}
} finally {
reopened.close()
}
} finally {
for (const resolve of finish.values()) resolve()
await harness.dispose()
database.close()
await rm(directory, { recursive: true, force: true })
}
})
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.each([false, true])('recovers user Stop only while its remote result is uncertain (confirmed=%s)', async (confirmed) => {
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
})
})
if (confirmed) {
throw new RemotePromptCancelledError('请求已取消')
}
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)
if (confirmed) {
await vi.waitFor(() => expect(
harness.assistantDatabase.endRecoverableRemoteTask
).toHaveBeenCalledWith(requestId, '请求已取消', 'cancelled'))
expect(harness.assistantDatabase.appendTaskEvent).not.toHaveBeenCalledWith(
requestId, 'error', expect.anything()
)
expect(harness.webContents.send).not.toHaveBeenCalledWith(
ipcChannels.remoteProjectRecoveryProgress,
expect.anything()
)
} else {
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('rejects unconfirmed image capability before accepting a queued input', async () => {
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false,
async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const }
})
harness.contextManager.hasImageInputs.mockReturnValue(true)
try {
await expect(electronMocks.handlers.get(ipcChannels.conversationQueueEnqueueUser)?.(trustedEvent(harness.webContents), {
conversationId: '00000000-0000-4000-8000-000000000731',
runtimeSelection: { provider: 'auto' }, workMode: 'ask', prompt: 'Read the picture',
attachments: [{ id: '00000000-0000-4000-8000-000000000734', name: 'image.png', size: 10, preview: '', kind: 'image' }],
knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto'
})).rejects.toThrow('图片输入')
expect(harness.assistantDatabase.enqueueConversationUserInput).not.toHaveBeenCalled()
expect(harness.contextManager.remove).not.toHaveBeenCalled()
} finally { await harness.dispose() }
})
it('claims an idle user message before notifying the renderer and accepts its dispatch', 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
)
harness.webContents.send.mockClear()
expect(
await enqueueHandler?.(trustedEvent(harness.webContents), input)
).toEqual(item)
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item, input }
)
const queueChangeIndex = harness.webContents.send.mock.calls.findIndex(
([channel]) => channel === ipcChannels.conversationQueueChanged
)
expect(queueChangeIndex).toBeGreaterThanOrEqual(0)
expect(
harness.assistantDatabase.claimConversationQueueItem.mock.invocationCallOrder[0]
).toBeLessThan(
harness.webContents.send.mock.invocationCallOrder[queueChangeIndex] ?? 0
)
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('retains user-enabled knowledge scopes when other applications are disabled', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111'
let finish: (() => void) | undefined
const held = new Promise<void>(resolve => { finish = resolve })
const run = vi.fn(async function* (request: { requestId: string; knowledgeLibraryIds: string[] }) {
if (request.requestId.endsWith('21')) await held
yield { requestId: request.requestId, type: 'done' }
})
const retrieveMany = vi.fn(async () => [])
const gateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => ['knowledge_search']), drainReferences: vi.fn(() => []), revoke: vi.fn() }
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, supportsScopedDataTools: true, run }, undefined, 'always', undefined, false, undefined, {
database: { listKnowledgeBases: vi.fn(() => [{ id: libraryId, name: 'Knowledge' }]) }, retrieveMany
}, gateway)
const request = { prompt: 'use saved knowledge', workMode: 'ask', knowledgeLibraryIds: [libraryId], knowledgeRetrievalMode: 'always' }
try {
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000021', conversationId: 'in-flight' })
await vi.waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(retrieveMany).toHaveBeenCalledOnce()
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: false, magicNotesEnabled: false })
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000022', conversationId: 'disabled' })
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2))
expect(run.mock.calls[0]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(run.mock.calls[1]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(retrieveMany).toHaveBeenCalledTimes(2)
expect(gateway.grant).toHaveBeenCalledTimes(2)
expect(harness.assistantDatabase.updateTaskStatus).not.toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'cancelled')
finish!()
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'completed'))
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: true, magicNotesEnabled: false })
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000023', conversationId: 'restored' })
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(3))
expect(run.mock.calls[2]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(retrieveMany).toHaveBeenCalledTimes(3)
expect(request.knowledgeLibraryIds).toEqual([libraryId])
} finally {
finish!()
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
browserTabId?: BrowserTabId
}> = []
const runtime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
supportsScopedDataTools: true,
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
trustedInstructions?: string
browserTabId?: BrowserTabId
}) {
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 primaryTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000401'
)
const siblingTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000402'
)
const browserControl = {
listTabs: vi.fn(() => [
{ tabId: siblingTabId, primary: false },
{ tabId: primaryTabId, primary: true }
]),
getVisibleTabId: vi.fn(() => undefined),
acquireTabUsage: vi.fn(
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release: vi.fn()
})
),
createTab: vi.fn(),
onState: vi.fn(() => () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds },
browserControl
)
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',
primaryTabId,
expect.objectContaining({
conversationId: 'browser-execute',
tabId: primaryTabId,
owner: executeRequestId
})
)
expect(receivedRequests[0]?.knowledgeCapabilityToken).toBeUndefined()
expect(receivedRequests[1]).toMatchObject({
knowledgeCapabilityToken: 'browser-capability',
browserTabId: primaryTabId,
trustedInstructions: expect.stringContaining('browser_navigate')
})
expect(browserControl.listTabs).toHaveBeenCalledWith(
'browser-execute',
harness.webContents.id
)
expect(browserControl.createTab).not.toHaveBeenCalled()
expect(browserControl.acquireTabUsage).toHaveBeenCalledWith(
'browser-execute',
primaryTabId,
executeRequestId,
harness.webContents.id
)
await harness.dispose()
})
it('prefers the owner window visible browser tab over the primary tab', async () => {
const requestId = '00000000-0000-4000-8000-000000000028'
const primaryTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000411'
)
const visibleTabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000412'
)
const received: Array<{ browserTabId?: BrowserTabId }> = []
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string; browserTabId?: BrowserTabId }) {
received.push(request)
yield { requestId: request.requestId, type: 'done' }
}
}
const release = vi.fn()
const knowledgeGateway = {
grant: vi.fn(() => 'browser-capability'),
getAvailableToolNames: vi.fn(() => ['browser_navigate']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const browserControl = {
listTabs: vi.fn(() => [
{ tabId: primaryTabId, primary: true },
{ tabId: visibleTabId, primary: false }
]),
getVisibleTabId: vi.fn(() => visibleTabId),
acquireTabUsage: vi.fn(
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release
})
),
createTab: vi.fn(),
onState: vi.fn(() => () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) },
browserControl
)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'visible-browser',
prompt: '使用当前网页',
workMode: 'execute',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(received[0]?.browserTabId).toBe(visibleTabId)
expect(browserControl.acquireTabUsage).toHaveBeenCalledWith(
'visible-browser',
visibleTabId,
requestId,
harness.webContents.id
)
expect(browserControl.createTab).not.toHaveBeenCalled()
await harness.dispose()
})
it('completes the runtime request and releases its lease after browser lease abort', async () => {
const requestId = '00000000-0000-4000-8000-000000000031'
const tabId = browserTabIdSchema.parse(
'00000000-0000-4000-8000-000000000423'
)
const leaseController = new AbortController()
const release = vi.fn()
const lease = {
conversationId: 'browser-lease-abort',
tabId,
owner: requestId,
signal: leaseController.signal,
release
}
let runtimeSignal: AbortSignal | undefined
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }, signal: AbortSignal) {
runtimeSignal = signal
leaseController.abort(new Error('browser tab closed'))
signal.throwIfAborted()
yield { requestId: request.requestId, type: 'done' }
}
}
let grantedLease: { release(): void } | undefined
const knowledgeGateway = {
grant: vi.fn((...args: unknown[]) => {
grantedLease = args[7] as { release(): void }
return 'browser-capability'
}),
getAvailableToolNames: vi.fn(() => ['browser_navigate']),
drainReferences: vi.fn(() => []),
revoke: vi.fn(() => grantedLease?.release())
}
const browserControl = {
listTabs: vi.fn(() => [{ tabId, primary: true }]),
getVisibleTabId: vi.fn(() => tabId),
acquireTabUsage: vi.fn(() => lease),
createTab: vi.fn(),
onState: vi.fn(() => () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) },
browserControl
)
try {
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: lease.conversationId,
prompt: 'Continue after the browser tab closes',
workMode: 'execute',
knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(release).toHaveBeenCalledOnce())
expect(grantedLease).toBe(lease)
expect(lease.signal.aborted).toBe(true)
expect(runtimeSignal?.aborted).toBe(false)
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
const publicEvents = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent)
.map(([, payload]) => payload)
expect(publicEvents).toContainEqual({ requestId, type: 'done' })
expect(publicEvents).not.toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'error' })
]))
expect(knowledgeGateway.revoke).toHaveBeenCalledWith('browser-capability')
} finally {
await harness.dispose()
}
})
it.each(['error', 'cancel'] as const)(
'releases a request-created browser tab lease on runtime %s',
async (outcome) => {
const requestId = outcome === 'error'
? '00000000-0000-4000-8000-000000000029'
: '00000000-0000-4000-8000-000000000030'
const tabId = browserTabIdSchema.parse(
outcome === 'error'
? '00000000-0000-4000-8000-000000000421'
: '00000000-0000-4000-8000-000000000422'
)
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }, signal: AbortSignal) {
markStarted()
if (outcome === 'error') throw new Error('runtime failed')
await new Promise<void>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true
})
})
yield { requestId: request.requestId, type: 'done' }
}
}
const release = vi.fn()
let grantedLease:
| { release(): void }
| undefined
const knowledgeGateway = {
grant: vi.fn((...args: unknown[]) => {
grantedLease = args[7] as { release(): void }
return 'browser-capability'
}),
getAvailableToolNames: vi.fn(() => ['browser_navigate']),
drainReferences: vi.fn(() => []),
revoke: vi.fn(() => grantedLease?.release())
}
const createTab = vi.fn()
const browserControl = {
listTabs: vi.fn(() => []),
getVisibleTabId: vi.fn(() => undefined),
createTab,
reserveRequestTab: vi.fn((conversationId: string, owner: string) => ({
conversationId,
tabId,
owner,
signal: new AbortController().signal,
release
})),
acquireTabUsage: vi.fn(
(conversationId: string, acquiredTabId: BrowserTabId, owner: string) => ({
conversationId,
tabId: acquiredTabId,
owner,
signal: new AbortController().signal,
release
})
),
onState: vi.fn(() => () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) },
browserControl
)
const event = trustedEvent(harness.webContents)
await harness.handler?.(event, {
requestId,
conversationId: `browser-${outcome}`,
prompt: '使用浏览器',
workMode: 'execute',
knowledgeLibraryIds: []
})
await started
if (outcome === 'cancel') {
harness.cancelHandler?.(event, requestId)
}
await vi.waitFor(() => expect(release).toHaveBeenCalledOnce())
expect(createTab).not.toHaveBeenCalled()
expect(browserControl.acquireTabUsage).not.toHaveBeenCalled()
expect(browserControl.reserveRequestTab).toHaveBeenCalledWith(
`browser-${outcome}`,
requestId,
harness.webContents.id
)
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
'browser-capability'
)
await harness.dispose()
}
)
it.each(['ask', 'execute'] as const)(
'does not grant or advertise scoped data tools to external OpenCode in %s mode',
async (workMode) => {
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.each([false, true])('preflights always-retrieve mode with external=%s and injects bounded untrusted evidence', async (external) => {
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: external ? undefined : documentId,
sourceId: external ? undefined : '55555555-5555-4555-8555-555555555555',
chunkId: external ? undefined : chunkId,
external: external ? { kind: 'external' as const, provider: 'dify' as const, instanceId: libraryId, remoteKnowledgeBaseId: 'remote-kb', remoteChunkId: 'remote-chunk', location: 'Remote section 3' } : undefined,
documentTitle: '离线部署.md',
sourceDisplayName: '产品手册',
sourceType: 'file' as const,
location: '第 2 节',
snippet: '离线部署需要先校验安装包',
relevance: external ? undefined : 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
}
]
}
}
if (external) {
retrievalResponse.context.groups = []
retrievalResponse.results.push({ ...retrievalResponse.results[0]!, rank: 2, external: { ...retrievalResponse.results[0]!.external!, remoteChunkId: 'second-chunk' } })
}
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,
supportsScopedDataTools: !external,
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: external ? 2 : 1
}),
expect.objectContaining({
type: 'source-references',
references: external ? expect.arrayContaining([
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'remote-chunk' }) }),
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second-chunk' }) })
]) : [expect.objectContaining({ chunkId, documentId })]
})
])
)
expect(run).toHaveBeenCalledOnce()
if (external) expect(run.mock.calls[0]![0].prompt).toContain('"locator":"Remote section 3"')
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 retrieveMany = vi.fn(() => {
throw new Error('must not search')
})
const harness = createHarness(
{
capability: 'chat',
supportsToolExecution: true
},
undefined,
'always',
undefined,
false,
undefined,
{
database: { listKnowledgeBases: vi.fn(() => []) },
retrieveMany
}
)
await expect(
harness.knowledgeSearchHandler?.(
trustedEvent(harness.webContents),
{ libraryIds: [], query: 'anything' }
)
).resolves.toEqual([])
expect(retrieveMany).not.toHaveBeenCalled()
await harness.dispose()
})
it('routes external UI payloads through IPC, the production service and citation search', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-external-ipc-'))
const fetcher = vi.fn<typeof fetch>(async (_url, init) => Response.json(init?.method === 'POST'
? { records: [{ score: 0.75, segment: { id: 'remote-chunk', content: 'Policy evidence', document: { id: 'remote-document', name: 'Policy' } } }] }
: { data: [{ id: 'dataset', name: 'Policy' }], has_more: false }))
const service = new KnowledgeService({
databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'),
externalFetcher: fetcher,
credentialCipher: { isAvailable: () => true, encrypt: value => Buffer.from(value), decrypt: value => value.toString() }
})
await service.initialize()
const harness = createHarness({ capability: 'chat' }, undefined, 'always', undefined, false, undefined, service as unknown as Record<string, unknown>)
const event = trustedEvent(harness.webContents)
const invoke = (channel: string, input?: unknown) => electronMocks.handlers.get(channel)!(event, input)
try {
const instance = await invoke(ipcChannels.externalInstancesSave, {
name: 'External instance', provider: 'dify', baseUrl: 'https://kb.example', enabled: true,
credential: { action: 'replace', value: 'test-key' }
}) as { id: string }
expect(instance).not.toHaveProperty('credential')
await expect(invoke(ipcChannels.externalCatalogList, { instanceId: instance.id })).resolves.toMatchObject({ items: [{ id: 'dataset' }] })
await expect(invoke(ipcChannels.externalBindingsCreate, { instanceId: instance.id })).rejects.toThrow()
const snapshot = await invoke(ipcChannels.externalBindingsCreate, {
instanceId: instance.id, remoteKnowledgeBaseId: 'dataset', name: 'Policy', remoteName: 'Policy',
testQuery: 'policy', commonConfig: { resultLimit: 6, requestTimeoutMs: 1000, maxSnippetCharacters: 4000 },
providerConfig: { provider: 'dify', useDatasetDefaults: true }
}) as { libraries: Array<{ id: string; kind: string }> }
expect(snapshot.libraries[0]?.kind).toBe('external')
const libraryId = snapshot.libraries[0]!.id
await expect(invoke(ipcChannels.knowledgeRetrieve, { knowledgeBaseId: libraryId, query: 'policy' })).resolves.toMatchObject({
results: [{ external: { remoteChunkId: 'remote-chunk' }, snippet: 'Policy evidence' }]
})
await expect(invoke(ipcChannels.knowledgeSearch, { libraryIds: [libraryId], query: 'policy' })).resolves.toMatchObject([
{ libraryId, documentId: undefined, snippet: 'Policy evidence', external: { remoteDocumentId: 'remote-document', remoteChunkId: 'remote-chunk', providerScore: 0.75 } }
])
await invoke(ipcChannels.externalInstancesSetEnabled, { instanceId: instance.id, enabled: false })
fetcher.mockClear()
await expect(invoke(ipcChannels.knowledgeSearch, { libraryIds: [libraryId], query: 'policy' })).rejects.toThrow('EXTERNAL_KB_DISABLED')
expect(fetcher).not.toHaveBeenCalled()
} finally {
await harness.dispose()
await service.dispose()
await rm(directory, { recursive: true, force: true })
}
})
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('resolves the latest project rule for ordinary runs and lets explicit selections override it', async () => {
const requestRuntime = {
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield { requestId: request.requestId, type: 'done' }
}
}
const selectedRuntimes = {
getRuntime: vi.fn(async () => requestRuntime),
releaseConversation: vi.fn(async () => undefined)
}
const harness = createHarness(requestRuntime, undefined, 'always', undefined, false, selectedRuntimes)
const projectId = '00000000-0000-4000-8000-000000000101'
const fixed = { provider: 'continue', profileId: '00000000-0000-4000-8000-000000000042' }
const project = { id: projectId, rootPath: 'C:\\ProjectWorkspace' }
harness.assistantDatabase.getProject.mockReturnValue({ ...project, runtimeSelection: { provider: 'opencode' } })
const event = trustedEvent(harness.webContents)
await harness.handler?.(event, {
projectId, conversationId: 'project-rule-one',
requestId: '00000000-0000-4000-8000-000000000011', prompt: 'one', workMode: 'ask'
})
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith({ provider: 'opencode' }, expect.anything())
harness.assistantDatabase.getProject.mockReturnValue({ ...project, runtimeSelection: fixed })
await harness.handler?.(event, {
projectId, conversationId: 'project-rule-two',
requestId: '00000000-0000-4000-8000-000000000012', prompt: 'two', workMode: 'ask'
})
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith(fixed, expect.anything())
await harness.handler?.(event, {
projectId, conversationId: 'project-rule-three', runtimeSelection: { provider: 'model' },
requestId: '00000000-0000-4000-8000-000000000013', prompt: 'three', workMode: 'ask'
})
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith({ provider: 'model' }, expect.anything())
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_apply_patch',
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 })
}
})
})