Files
goodbuddy/src/main/ipc.test.ts
T

13972 lines
523 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import type {} from '@testing-library/jest-dom/vitest'
import { EventEmitter } from 'node:events'
import { setImmediate as nextTurn } from 'node:timers/promises'
import { assistantStorageMethods } from './desktop-storage-contracts'
import { asyncSupervisionStorage } from '../../tests/support/async-supervision-storage'
import { TestKnowledgeService } from '../../tests/support/knowledge-test-service'
import { AgentModelCallLedger } from '../agent-daemon/agent-model-gateway'
import { createServer } from 'node:http'
import {
mkdtemp,
mkdir,
readFile,
readdir,
realpath,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
import { execFileSync } from 'node:child_process'
import { ipcChannels } from '../shared/ipc-channels'
import type {
AssistantProject,
AssistantSchedule,
ConversationSnapshot,
ConversationQueueItem
} from '../shared/assistant-contracts'
import {
browserTabIdSchema,
defaultModelProfileId,
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 { ChannelSettingsStore } from './channels/channel-settings-store'
import { ChannelService } from './channels/channel-service'
import { SqliteChannelOutbox } from './channels/sqlite-channel-state'
import type { ChannelDriver, ChannelInboundHandler } from './channels/channel-driver'
import { SubagentService, createSubagentRuntime } from './assistant/subagent-service'
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
import { DocumentResultStorage } from './document-result-storage'
import { ImageGenerationService } from './agent/image-generation-service'
import { ApplicationSettingsStore } from './application-settings-store'
import * as desktopNotification from './desktop-notification'
import type { AgentExecutionRequest } from './agent/runtime'
import type { SelectedRuntimeResolver } from './agent/selected-runtime-manager'
import { ExecutionSpaceResolver, type ExecutionSpaceDescriptor } from './execution-space'
import type { WorkspaceAccess } from './workspace'
import { KnowledgeDatabase } from './knowledge/knowledge-database'
import { BrowserNavigationStoppedError } from './browser/browser-service'
import { RemotePromptCancelledError } from './agent/acp-remote-runtime'
import { CapabilityService } from './capabilities/capability-service'
import { ObsidianService } from './obsidian'
import { ReadonlyQueryReader } from './readonly-query-reader'
import { packageObsidianMcpVault } from './obsidian/package-mcpvault'
import {
registerIpcHandlers as registerProductionIpcHandlers,
sendRemoteEnvironmentUpdateProgress,
sendRemoteProjectSaveProgress
} from './ipc'
// Most IPC tests use partial databases. Real database fixtures retain their timing implementation.
const beforeStorageCall = new WeakMap<object, (method: string) => Promise<void>>()
const storageReaders = new WeakMap<object, ReadonlyQueryReader>()
function registerIpcHandlers(...args: Parameters<typeof registerProductionIpcHandlers>): ReturnType<typeof registerProductionIpcHandlers> {
const database = args[7]
database.startExecutionTiming ??= vi.fn()
database.endExecutionTiming ??= vi.fn()
args[45] ??= asyncSupervisionStorage(database as AssistantDatabase, async () => { if (!vi.isFakeTimers()) await nextTurn(); else await Promise.resolve() })
args[7] = Object.fromEntries(assistantStorageMethods.map(method => [method, async (...input: unknown[]) => {
if (!vi.isFakeTimers()) await nextTurn(); else await Promise.resolve()
await beforeStorageCall.get(database)?.(method)
const target = database as unknown as Record<string, (...values: unknown[]) => unknown>
const reader = storageReaders.get(database)
const reads: Record<string, string> = { listSupervisionResults: 'supervisionOverview', getSupervisionGraph: 'supervisionGraph', getSupervisionStories: 'supervisionStories' }
if (reader && reads[method]) return reader.call(reads[method]!, input)
return await target[method]!.apply(database, input)
}])) as typeof database
const removeStatsListener = (database as Partial<AssistantDatabase>).onExecutionStatsChanged?.(() => {
args[0].webContents.send(ipcChannels.tasksExecutionStatsChanged)
})
const dispose = registerProductionIpcHandlers(...args)
return async () => { try { await dispose() } finally { removeStatsListener?.() } }
}
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 region = await readFile(
join(process.cwd(), 'src', 'main', 'local-tool-environment', 'local-tool-environment-ipc.ts'),
'utf8'
)
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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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(
await 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
)
await expect(
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.(event)
).resolves.toBe('Get-Location\r')
expect(electronMocks.readClipboardText).toHaveBeenCalledOnce()
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
event,
'## Copied response'
)
expect(electronMocks.writeClipboardText).toHaveBeenCalledWith(
'## Copied response'
)
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.({
sender: { ...webContents, id: 99 },
senderFrame: webContents.mainFrame
}))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
event,
{ text: 'invalid' }
))).rejects.toThrow()
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
{
sender: { ...webContents, id: 99 },
senderFrame: webContents.mainFrame
},
'untrusted'
))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(ipcChannels.terminalWrite)?.(
{
sender: { ...webContents, id: 99 },
senderFrame: webContents.mainFrame
},
{
sessionId: snapshot.sessionId,
data: 'untrusted'
}
))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, {
sessionId: snapshot.sessionId,
cols: 0,
rows: 28
}))).rejects.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,
invoke: vi.fn(),
fetch: vi.fn<typeof fetch>(),
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(async () => 'Get-Location\r'),
writeClipboardText: vi.fn(async () => {})
}
})
const channelMocks = vi.hoisted(() => ({
executor: undefined as
| ((
message: {
channel: string
eventId: string
senderId: string
conversationId: string
conversationType: 'direct' | 'group'
text: string
mentioned: boolean
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: import('../shared/shortcut').GlobalShortcutSettingsSnapshot = {
platform: 'win32',
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,
{} 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(
await electronMocks.handlers.get(ipcChannels.appInfo)?.(event)
).toMatchObject({
shortcut: 'Ctrl + Alt + K',
shortcutStatus: 'registered'
})
for (const status of ['disabled', 'conflict', 'failed'] as const) {
shortcutSettingsService.getSnapshot.mockReturnValueOnce({
...shortcutSnapshot,
registered: false,
registeredAccelerator: undefined,
status
})
expect(await electronMocks.handlers.get(ipcChannels.appInfo)?.(event)).toMatchObject({
shortcut: '',
shortcutStatus: status
})
}
expect(
await 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))
await expect(Promise.resolve().then(() => importFiles(event, { paths: [''] }))).rejects.toThrow()
await expect(Promise.resolve().then(() => importFiles({ ...event, senderFrame: {} }, { paths: ['C:\\scan.pdf'] }))).rejects.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(
await 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(
await 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()
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(
ipcChannels.capabilitiesCreateBrowserProfile
)?.(event, {
name: '工作配置',
executable: 'C:\\unsafe.exe'
}))).rejects.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)
const capabilityChannels = [
ipcChannels.capabilitiesSnapshot,
ipcChannels.runtimeExtensionsSnapshot,
ipcChannels.runtimeExtensionsApply,
ipcChannels.capabilitiesImportSkill,
ipcChannels.capabilitiesRemoveSkill,
ipcChannels.capabilitiesToggleSkill,
ipcChannels.capabilitiesAssignSkill,
ipcChannels.capabilitiesUpdateObsidianSettings,
ipcChannels.capabilitiesTestObsidianConnection,
ipcChannels.capabilitiesSelectObsidianVault,
ipcChannels.capabilitiesToggleBuiltinMcp,
ipcChannels.capabilitiesAssignBuiltinMcp,
ipcChannels.capabilitiesSaveMcp,
ipcChannels.capabilitiesRemoveMcp,
ipcChannels.capabilitiesTestMcp,
ipcChannels.capabilitiesToggleWebSearch,
ipcChannels.capabilitiesTestWebSearch,
ipcChannels.capabilitiesToggleComputer,
ipcChannels.capabilitiesConfigureComputer,
ipcChannels.capabilitiesDiagnoseComputer,
ipcChannels.capabilitiesCreateBrowserProfile,
ipcChannels.capabilitiesRenameBrowserProfile,
ipcChannels.capabilitiesDefaultBrowserProfile,
ipcChannels.capabilitiesRemoveBrowserProfile
]
const registrations = electronMocks.handle.mock.calls.map(([channel]) => channel)
const start = registrations.indexOf(ipcChannels.capabilitiesSnapshot)
expect(registrations.slice(start - 1, start + capabilityChannels.length + 1)).toEqual([
ipcChannels.expertsRemove,
...capabilityChannels,
ipcChannels.contextSelectFiles
])
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(
ipcChannels.capabilitiesDiagnoseComputer
)?.(
{
sender: {},
senderFrame: webContents.mainFrame
},
'host-browser-control'
))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
for (const channel of capabilityChannels) {
expect(electronMocks.removeHandler).toHaveBeenCalledWith(channel)
expect(await electronMocks.handlers.has(channel)).toBe(false)
}
})
})
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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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(
await electronMocks.handlers.get(
ipcChannels.documentOcrModelsProgress
)?.(event)
).toEqual({ operations: [] })
expect(
documentOcrModelManager.getProgressSnapshot
).toHaveBeenCalledOnce()
await expect(Promise.resolve().then(() =>
electronMocks.handlers
.get(ipcChannels.documentOcrModelsProgress)
?.({
sender: {},
senderFrame: webContents.mainFrame
}))).rejects.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', () => ({
net: { fetch: electronMocks.fetch },
contextBridge: { exposeInMainWorld: (name: string, value: unknown) => vi.stubGlobal(name, value) },
ipcRenderer: { invoke: electronMocks.invoke },
webUtils: {},
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('./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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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()
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(
ipcChannels.runtimeExtensionsSnapshot
)?.({
sender: {},
senderFrame: webContents.mainFrame
}))).rejects.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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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'
}
}
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'
},
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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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'
}
}
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
}
})
).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
}
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 }
}
}
}
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 = { on: vi.fn(), removeListener: vi.fn(),
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
}
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,
{} 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 }
}
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
}
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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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' }
}
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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} as never,
vi.fn(async () => undefined)
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
expect(
await 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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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 = { on: vi.fn(), removeListener: vi.fn(),
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 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,
{} 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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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()
await expect(Promise.resolve().then(() =>
electronMocks.handlers
.get(ipcChannels.windowIsMaximized)
?.({
sender: {},
senderFrame: webContents.mainFrame
}))).rejects.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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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)
const source = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-import-'))
temporaryDirectories.push(source)
await mkdir(join(rootPath, 'selected'))
await writeFile(join(source, 'binary.png'), Buffer.from([0, 255, 128, 1]))
const importFiles = electronMocks.handlers.get(ipcChannels.workspaceImportFiles)!
electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: true, filePaths: [] })
await expect(importFiles(event, { projectId, path: 'selected' })).resolves.toEqual({ imported: [], failed: [] })
electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: [join(source, 'binary.png')] })
await expect(importFiles(event, { projectId, path: 'selected' })).resolves.toEqual({ imported: ['selected/binary.png'], failed: [] })
expect(await readFile(join(rootPath, 'selected/binary.png'))).toEqual(Buffer.from([0, 255, 128, 1]))
await expect(importFiles(event, { projectId, path: '../outside' })).rejects.toThrow()
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'
},
kind: 'user',
status: 'active',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}))
}
const webContents = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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 a timing snapshot', async () => {
const summary = { durationMs: 5000, runningCount: 0, incomplete: true, asOf: 10000, taskDurations: [{ id: 'task', durationMs: 5000, runningCount: 0, incomplete: true }] }
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getExecutionStats: vi.fn(() => summary)
}
const webContents = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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 }]) {
expect(await handler(event, scope)).toEqual(summary)
expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope)
}
await expect(Promise.resolve().then(() => handler(event, {}))).rejects.toThrow()
await expect(Promise.resolve().then(() => handler(event, { projectId: id, conversationId: id }))).rejects.toThrow()
await expect(Promise.resolve().then(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id }))).rejects.toThrow()
expect(assistantDatabase.getExecutionStats).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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} as never,
vi.fn(async () => {})
)
const handler = electronMocks.handlers.get(
ipcChannels.tokenUsageSummary
)
expect(handler).toBeDefined()
expect(
await 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.each(['current', 'profile', 'image', 'missing'] as const)(
'routes direct model manual compaction using %s selection', async (source) => {
const profile = {
id: crypto.randomUUID(), name: 'Selected', baseUrl: 'https://models.example',
modelName: 'selected-text', protocol: 'anthropic-messages', authentication: 'none'
}
const fallback = { ...profile, id: crypto.randomUUID(), modelName: 'default-text' }
const summary = { ...profile, id: crypto.randomUUID(), modelName: 'summary-text' }
const selection = { provider: 'model' as const, profileId: source === 'missing' ? crypto.randomUUID() : profile.id }
const settings = {
provider: 'model', workspacePath: 'C:\\Workspace', defaultModelProfileId: fallback.id,
modelProfiles: [fallback, { ...profile, protocol: source === 'image' ? 'openai-images-generations' : profile.protocol }, summary],
continueModelProfile: fallback,
contextCompression: { enabled: false, modelSource: source === 'profile' ? { kind: 'profile', profileId: summary.id } : { kind: 'current' } }
}
const messages = [
{ id: crypto.randomUUID(), role: 'user', content: 'earlier question', state: 'complete' },
{ id: crypto.randomUUID(), role: 'assistant', content: 'earlier answer', state: 'complete' }
]
const conversationId = crypto.randomUUID()
const database = {
queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
// Stored conversations hold a layer; the renderer sends the resolved selection.
getConversation: vi.fn(() => ({ id: conversationId, runtimeSelection: {
provider: 'model', model: { kind: 'profile', profileId: selection.profileId }
}, messages })),
createTask: vi.fn(), updateTaskStatus: vi.fn()
}
const compactor = {
compactConversation: vi.fn(async () => ({ result: {
provider: 'model', strategy: 'goodbuddy-summary', compacted: true, detail: 'Compacted'
} })),
dispose: vi.fn(async () => {})
}
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(compactor)
const webContents = { on: vi.fn(), removeListener: vi.fn(),
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, {} as never, 'CommandOrControl+Shift+Space',
{ getResolvedSettings: vi.fn(async () => settings) } as never,
{} as never, { clear: vi.fn() } as never, {} as never, database as never,
{} as never, vi.fn(async () => {})
)
try {
const result = electronMocks.handlers.get(ipcChannels.agentCompactConversation)?.(
{ sender: webContents, senderFrame: webContents.mainFrame },
{ requestId: crypto.randomUUID(), conversationId, runtimeSelection: selection,
history: messages.map(({ role, content }) => ({ role, content })),
historyMessageIds: messages.map(({ id }) => id) }
)
if (source === 'image' || source === 'missing') {
await expect(result).rejects.toThrow()
expect(runtimeFactoryMocks.createModelProfileRuntime).not.toHaveBeenCalled()
} else {
await expect(result).resolves.toMatchObject({ provider: 'model', compacted: true })
expect(runtimeFactoryMocks.createModelProfileRuntime).toHaveBeenCalledWith(
'C:\\Workspace', expect.objectContaining({ defaultModelProfileId: profile.id }),
source === 'profile' ? summary : profile
)
expect(compactor.dispose).toHaveBeenCalledOnce()
expect(database.updateTaskStatus).toHaveBeenCalledWith(expect.any(String), 'completed')
}
} finally {
await dispose()
}
}
)
it('pins and unpins without attachment reconciliation or import checks', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-pin-ipc-'))
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
const assets = new ConversationAttachmentStorage(root, results)
let dispose: ReturnType<typeof registerIpcHandlers> | undefined
try {
database.initialize(root)
const conversationId = '00000000-0000-4000-8000-000000000301'
const messageId = '00000000-0000-4000-8000-000000000302'
database.saveLocalConversations([{
header: { id: conversationId, title: 'Pin regression', updatedAt: 1 },
messages: [{ id: messageId, role: 'user', content: 'Keep this message', createdAt: 1, state: 'complete' }]
}])
const assetId = assets.beginParsing(conversationId, 'pending.pdf', Buffer.from('original'))
assets.reference(conversationId, 'draft', conversationId, [assetId])
assets.reference(conversationId, 'message', messageId, [assetId])
const before = database.getConversation(conversationId)
const reconcile = vi.spyOn(assets, 'reconcile')
const collect = vi.spyOn(assets, 'collect')
const hasOwner = vi.spyOn(database, 'hasAttachmentOwner')
const contextManager = {
assets,
activeContextIds: vi.fn(() => []),
cancelUnavailableImport: vi.fn(),
clear: vi.fn()
}
const webContents = {
on: vi.fn(), removeListener: vi.fn(),
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()
}
dispose = registerIpcHandlers(
window as never, { capability: 'text' } as never,
'CommandOrControl+Shift+Space', {} as never, {} as never,
contextManager as never, {} as never, database,
{} as never, vi.fn(async () => {})
)
const event = { sender: webContents, senderFrame: webContents.mainFrame }
const pin = electronMocks.handlers.get(ipcChannels.conversationsSetPinned)!
expect(pin).toBeTypeOf('function')
webContents.send.mockClear()
for (const pinned of [true, true, false]) {
expect(await pin(event, { conversationId, pinned })).toBeUndefined()
expect(database.getConversation(conversationId)).toEqual({ ...before, pinned })
}
expect(webContents.send.mock.calls).toEqual([
[ipcChannels.conversationsChanged],
[ipcChannels.conversationsChanged],
[ipcChannels.conversationsChanged]
])
expect({
reconciliations: reconcile.mock.calls.length,
ownerChecks: hasOwner.mock.calls.length,
collections: collect.mock.calls.length,
activeContextReads: contextManager.activeContextIds.mock.calls.length,
importChecks: contextManager.cancelUnavailableImport.mock.calls.length
}).toEqual({ reconciliations: 0, ownerChecks: 0, collections: 0, activeContextReads: 0, importChecks: 0 })
expect(assets.draft(conversationId).map((asset) => asset.id)).toEqual([assetId])
expect(assets.get(assetId).parsingState).toBe('parsing')
expect(assets.original(assetId).data).toEqual(Buffer.from('original'))
webContents.send.mockClear()
await expect(Promise.resolve().then(() => pin(event, { conversationId, pinned: 'yes' }))).rejects.toThrow()
await expect(Promise.resolve().then(() => pin({ ...event, sender: {} }, { conversationId, pinned: true }))).rejects.toThrow()
await expect(Promise.resolve().then(() => pin(event, {
conversationId: '00000000-0000-4000-8000-000000000399', pinned: true
}))).rejects.toThrow()
expect(webContents.send).not.toHaveBeenCalled()
expect(database.getConversation(conversationId)).toEqual(before)
const setStoryGraph = electronMocks.handlers.get(ipcChannels.conversationsSetStoryGraph)!
expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(true)
for (const enabled of [false, true]) {
await setStoryGraph(event, { conversationId, enabled })
expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(enabled)
expect(database.getConversation(conversationId)).toEqual({ ...before, storyGraphEnabled: enabled })
}
await expect(Promise.resolve().then(() => setStoryGraph(event, { conversationId, enabled: 'false' }))).rejects.toThrow()
await expect(Promise.resolve().then(() => setStoryGraph({ ...event, sender: {} }, { conversationId, enabled: false }))).rejects.toThrow()
expect(await electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)).toBe(true)
expect(collect).toHaveBeenCalledOnce()
expect(contextManager.cancelUnavailableImport).toHaveBeenCalledOnce()
expect(assets.has(assetId)).toBe(false)
} finally {
await dispose?.()
assets.close()
await results.close()
database.close()
await rm(root, { recursive: true, force: true })
}
})
it('validates full handles across child adoption, stale/current saves and branching, and releases deleted history', async () => {
const parent = join(process.cwd(), 'temp', 'output-history-ipc')
await mkdir(parent, { recursive: true })
const root = await mkdtemp(join(parent, 'fixture-'))
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
const assets = new ConversationAttachmentStorage(root, results)
let dispose: ReturnType<typeof registerIpcHandlers> | undefined
try {
await database.initialize(root)
const conversationId = crypto.randomUUID(), messageId = crypto.randomUUID()
const childTaskId = crypto.randomUUID()
const outputId = crypto.randomUUID(), handle = `process:${outputId}`
const text = '\u4e2d\ud83d\ude00'.repeat(20000) + '-complete-tail'
const bytes = Buffer.from(text)
assets.outputCreate(childTaskId, handle)
for (let offset = 0; offset < bytes.length; offset += 65536) await assets.outputAppend(childTaskId, handle, bytes.subarray(offset, offset + 65536))
await assets.outputFinish(childTaskId, handle, bytes.length)
const reference = { handle, totalBytes: bytes.length, nextCursor: 0 }
const childHandle = `subagent:${crypto.randomUUID()}`, progressHandle = `rg:${crypto.randomUUID()}`
for (const outputHandle of [childHandle, progressHandle]) {
assets.outputCreate(childTaskId, outputHandle)
await assets.outputAppend(childTaskId, outputHandle, Buffer.from('child output'))
await assets.outputFinish(childTaskId, outputHandle, 12)
}
const childReference = { handle: childHandle, totalBytes: 12, nextCursor: 0 }
const progressReference = { ...childReference, handle: progressHandle }
const handles = [handle, childHandle, progressHandle]
const tool = { callId: 'call', name: 'Process', state: 'completed' as const, summary: 'Output', output: 'preview', outputReferences: [reference] }
const batch = [{ header: { id: conversationId, title: 'Output history', updatedAt: 1 }, messages: [{
id: messageId, role: 'assistant' as const, content: '', state: 'complete' as const, createdAt: 1,
tools: [tool], blocks: [{ id: crypto.randomUUID(), type: 'tool' as const, tool }],
subagents: [{ childTaskId, actor: { kind: 'direct-model' as const, label: '编程 Subagent' as const }, routingMode: 'native' as const,
state: 'completed' as const, outputReference: childReference,
progress: [{ id: crypto.randomUUID(), type: 'tool' as const, tool: { ...tool, outputReferences: [progressReference] } }] }]
}] }]
database.saveLocalConversations(batch)
for (const outputHandle of handles) assets.outputAdopt(childTaskId, outputHandle, conversationId, 'message', messageId)
const referenced = vi.spyOn(assets, 'reference')
const contextManager = { assets, clear: vi.fn(), activeContextIds: () => [], cancelUnavailableImport: vi.fn() }
const webContents = { on: vi.fn(), removeListener: vi.fn(), mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: () => 'file:///goodbuddy/index.html', send: vi.fn() }
const window = { webContents, isDestroyed: () => false, on: vi.fn(), removeListener: vi.fn() }
dispose = registerIpcHandlers(window as never, { capability: 'text' } as never,
'CommandOrControl+Shift+Space', {} as never, {} as never, contextManager as never,
{} as never, database, {} as never, vi.fn(async () => {}))
const event = { sender: webContents, senderFrame: webContents.mainFrame }
const save = electronMocks.handlers.get(ipcChannels.conversationsSaveLocal)!
await save(event, [{ ...batch[0], messages: [{ ...batch[0]!.messages[0], tools: [], blocks: [], subagents: [] }] }])
expect(referenced).toHaveBeenLastCalledWith(conversationId, 'message', messageId, [], [])
for (const outputHandle of handles) assets.outputRelease(childTaskId, outputHandle)
assets.collect()
const read = electronMocks.handlers.get(ipcChannels.contextReadOutput)!
for (const outputHandle of handles) expect(await read(event, { conversationId, handle: outputHandle })).toHaveProperty('content')
await save(event, batch)
expect(referenced).toHaveBeenLastCalledWith(conversationId, 'message', messageId,
handles.map(outputHandle => outputHandle.split(':')[1]), handles)
const saved = database.getConversation(conversationId)
const unfinishedHandle = `process:${crypto.randomUUID()}`
assets.outputCreate(childTaskId, unfinishedHandle)
for (const [invalidHandle, error] of [
[`process:${crypto.randomUUID()}`, '附件资源不存在'],
[unfinishedHandle, 'Full output is missing or unfinished'],
[`rg:${outputId}`, 'Full output is missing or unfinished']
] as const) {
await expect(save(event, [{ ...batch[0], messages: [{ ...batch[0]!.messages[0], content: 'must not persist',
tools: [{ ...tool, outputReferences: [reference, { ...reference, handle: invalidHandle }] }]
}] }])).rejects.toThrow(error)
expect(database.getConversation(conversationId)).toEqual(saved)
}
assets.outputRelease(childTaskId, unfinishedHandle)
let restored = '', cursor = 0
while (cursor < bytes.length) {
const page = await read(event, { conversationId, handle, cursor }) as import('../shared/conversation-output').ConversationOutputPage
expect(Buffer.byteLength(page.content)).toBeLessThanOrEqual(32768)
expect(page.nextCursor).toBeGreaterThan(cursor)
restored += page.content
cursor = page.nextCursor
}
expect(restored).toBe(text)
expect(await read(event, { conversationId, handle, limitBytes: 1 })).toMatchObject({ content: '\u4e2d', nextCursor: 3 })
await expect(read(event, { conversationId, handle, cursor: 1 })).rejects.toThrow('UTF-8')
await expect(read(event, { conversationId, handle, limitBytes: 32769 })).rejects.toThrow()
await expect(read(event, { conversationId: crypto.randomUUID(), handle })).rejects.toThrow('owner')
await expect(read({ ...event, sender: {} }, { conversationId, handle })).rejects.toThrow()
const branch = await electronMocks.handlers.get(ipcChannels.conversationsBranchLocal)!(event, { sourceConversationId: conversationId, title: 'Branch' }) as ConversationSnapshot
expect(branch.messages[0]?.tools?.[0]?.outputReferences).toEqual([reference])
expect(branch.messages[0]?.subagents).toBeUndefined()
expect(referenced).toHaveBeenLastCalledWith(branch.id, 'message', branch.messages[0]!.id,
[outputId], [handle])
await electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)
await save(event, [{ header: { id: branch.id, title: 'Header-only update', updatedAt: Date.now() }, messages: [] }])
expect(await read(event, { conversationId: branch.id, handle, cursor: bytes.length - 4 })).toMatchObject({ content: 'tail', eof: true })
for (const outputHandle of [childHandle, progressHandle]) expect(assets.has(outputHandle.split(':')[1]!)).toBe(false)
await electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, branch.id)
await expect(read(event, { conversationId: branch.id, handle })).rejects.toThrow()
for (const outputHandle of handles) expect(assets.has(outputHandle.split(':')[1]!)).toBe(false)
} finally {
await dispose?.()
assets.close()
await results.close()
database.close()
await rm(root, { recursive: true, force: true })
}
})
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: 'model' },
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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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(
await electronMocks.handlers.get(
ipcChannels.conversationsSaveLocal
)?.(event, batch)
).toBeUndefined()
expect(
assistantDatabase.saveLocalConversations
).toHaveBeenCalledWith(batch)
const branchInput = {
sourceConversationId: conversationId,
title: '增量会话 · 分支'
}
expect(
await electronMocks.handlers.get(
ipcChannels.conversationsBranchLocal
)?.(event, branchInput)
).toMatchObject({
branch: { sourceConversationId: conversationId },
title: branchInput.title
})
expect(
assistantDatabase.branchLocalConversation
).toHaveBeenCalledWith(branchInput)
expect(
await electronMocks.handlers.get(
ipcChannels.conversationsDeleteLocal
)?.(event, conversationId)
).toBe(true)
expect(
assistantDatabase.deleteLocalConversation
).toHaveBeenCalledWith(conversationId)
expect(contextManager.remove).toHaveBeenCalledWith(
queuedAttachmentId
)
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(
ipcChannels.conversationsSaveLocal
)?.(event, [
{
...batch[0],
header: {
...batch[0]!.header,
remote: {
channel: 'weixin',
accountDisplay: 'remote',
conversationType: 'direct'
}
}
}
]))).rejects.toThrow()
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(
ipcChannels.conversationsBranchLocal
)?.(event, {
sourceConversationId: 'not-a-uuid',
title: '无效分支'
}))).rejects.toThrow()
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(
ipcChannels.conversationsDeleteLocal
)?.(event, 'not-a-uuid'))).rejects.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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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(
await 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(
await 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,
}
],
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 = { on: vi.fn(), removeListener: vi.fn(),
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 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,
{} as never,
onRuntimeSettingsChanged,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
selectedRuntimes as never
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const settingsChannels = [
ipcChannels.runtimeSettingsGet,
ipcChannels.runtimeCustomizationGet,
ipcChannels.runtimeCustomizationUpdate,
ipcChannels.runtimeNativeSnapshot,
ipcChannels.runtimeSettingsUpdate,
ipcChannels.runtimeSettingsSelectWorkspace,
ipcChannels.runtimeSettingsDetect,
ipcChannels.runtimeSettingsSelectFile,
ipcChannels.runtimeSettingsOpenConfig,
ipcChannels.runtimeSettingsTestModel,
ipcChannels.runtimeSettingsTest
]
const registeredChannels = [...electronMocks.handlers.keys()]
const settingsStart = registeredChannels.indexOf(settingsChannels[0]!)
expect(registeredChannels.slice(settingsStart - 1, settingsStart + 12)).toEqual([
ipcChannels.agentCompactConversation,
...settingsChannels,
ipcChannels.sshHostsGet
])
for (const channel of settingsChannels) {
await expect(Promise.resolve().then(() =>
electronMocks.handlers.get(channel)?.({ ...event, sender: {} }, {})
)).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
}
expect(settingsStore.getRuntimeCustomization).not.toHaveBeenCalled()
expect(settingsStore.updateRuntimeCustomization).not.toHaveBeenCalled()
expect(selectedRuntimes.getNativeSnapshot).not.toHaveBeenCalled()
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()
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'
})
)
await expect(
electronMocks.handlers.get(ipcChannels.runtimeNativeSnapshot)?.(event, {
provider: 'opencode'
})
).resolves.toEqual(snapshot)
expect(selectedRuntimes.getNativeSnapshot).toHaveBeenLastCalledWith(
{ provider: 'opencode' },
expect.objectContaining({ kind: 'local', rootPath: 'C:\\DefaultWorkspace' })
)
await expect(
electronMocks.handlers.get(ipcChannels.runtimeNativeSnapshot)?.(event, {
provider: 'opencode', projectId: 'invalid'
})
).rejects.toThrow()
expect(selectedRuntimes.getNativeSnapshot).toHaveBeenCalledTimes(2)
const compactInput = {
requestId,
conversationId,
projectId,
// The renderer sends the resolved selection; OpenCode here uses its own configuration.
runtimeSelection: { provider: 'opencode' as const, runtimeConfig: true 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>,
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,
heartbeatEnabled?: boolean,
obsidianService?: ObsidianService,
nativeClientCoordinator?: Parameters<typeof registerIpcHandlers>[44],
nativeTerminalManager?: Parameters<typeof registerIpcHandlers>[40],
applicationSettingsStore?: ApplicationSettingsStore,
channelSettingsStore?: ChannelSettingsStore
) {
runtime.getStatus ??= vi.fn(async () => ({
id: runtime.runtimeId ?? 'model', available: true,
supportsToolExecution: runtime.supportsToolExecution ?? true
}))
const appendRemoteTaskEventOnce = vi.fn<
(input: RemoteTaskEventMockInput) => boolean
>(() => true)
const appendRemoteConversationTaskEventOnce =
vi.fn<AssistantDatabase['appendRemoteConversationTaskEventOnce']>(() => true)
const assistantDatabase = {
createTask: vi.fn(),
appendTaskEvent: vi.fn(),
appendRemoteTaskEventOnce,
appendRemoteConversationTaskEventOnce,
// Batch writes delegate to the per-event mocks by default so tests can
// assert both per-event payloads and the number of transactions.
appendRemoteTaskEventsOnce: vi.fn<
(inputs: RemoteTaskEventMockInput[]) => boolean[]
>((inputs): boolean[] => inputs.map((input): boolean => appendRemoteTaskEventOnce(input))),
appendRemoteConversationTaskEventsBatch: vi.fn<AssistantDatabase['appendRemoteConversationTaskEventsBatch']>(
({ events, ...owner }): boolean[] => events.map((event): boolean =>
appendRemoteConversationTaskEventOnce({ ...owner, ...event }))
),
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
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'
},
runtimeSelection: {
provider: 'model',
model: { kind: 'profile', 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: []
})),
isConversationStoryGraphEnabled: vi.fn(() => true),
setConversationStoryGraphEnabled: vi.fn(),
getProject: vi.fn<
(projectId: string) => Record<string, unknown>
>((projectId) => ({
id: projectId,
rootPath: 'C:\\ProjectWorkspace'
}))
}
const webContents = { on: vi.fn(),
id: 9,
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn(),
once: vi.fn(),
removeListener: vi.fn(),
isDestroyed: vi.fn(() => false)
}
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 getResolvedSettings = vi.fn(
async (): Promise<Record<string, unknown>> => ({
modelProfiles: [{ id: defaultModelProfileId, name: 'Default', modelName: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none' }],
defaultModelProfileId,
subagentSmartRoutingEnabled: smartRoutingEnabled
})
)
const getPolicySettings = vi.fn(
async (): Promise<Record<string, unknown>> => ({
subagentSmartRoutingEnabled: smartRoutingEnabled
})
)
const getApplicationSettings = vi.fn(
async (): Promise<Record<string, unknown>> => ({
magicNotesEnabled,
heartbeatEnabled,
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,
{} 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[42] = imageService
args[43] = obsidianService
args[44] = nativeClientCoordinator
args[40] = nativeTerminalManager
if (applicationSettingsStore) args[14] = applicationSettingsStore
args[13] = channelSettingsStore
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(runtime)
const dispose = registerIpcHandlers(...args)
return {
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
})
it.each([
['conversation', 'completed'], ['conversation', 'failed'],
['channel', 'completed'], ['channel', 'failed']
] as const)('uses the latest saved desktop notification preference for %s %s results', async (origin, status) => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notification-ipc-'))
const store = new ApplicationSettingsStore(join(directory, 'application.json'))
await store.update({ magicNotesEnabled: false })
const notify = vi.spyOn(desktopNotification, 'showDesktopNotificationWhenUnfocused').mockReturnValue(true)
let preference: 'default' | boolean | 'unreadable' = 'default'
const get = vi.spyOn(store, 'get')
const harness = createHarness({
capability: 'chat',
async *run(request: { requestId: string }) {
// Change the persisted choice after admission to detect stale startup/task snapshots.
if (typeof preference === 'boolean') await store.update({ desktopNotificationsEnabled: preference })
if (preference === 'unreadable') get.mockRejectedValueOnce(new Error('Settings unavailable'))
if (status === 'failed') throw new Error('Runtime failed')
yield { requestId: request.requestId, type: 'done' }
}
}, undefined, undefined, false, undefined, undefined, undefined, false,
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, store)
try {
for (preference of ['default', false, true, 'unreadable'] as const) {
notify.mockClear()
harness.webContents.send.mockClear()
harness.assistantDatabase.updateTaskStatus.mockClear()
if (origin === 'channel') {
await expect(channelMocks.executor!({
channel: 'wecom', eventId: crypto.randomUUID(), senderId: 'user-1',
conversationId: 'conversation-1', conversationType: 'direct',
text: 'Read only', mentioned: false
}, new AbortController().signal)).resolves.toMatchObject({ status })
} else {
const requestId = crypto.randomUUID()
await harness.handler!(trustedEvent(harness.webContents), {
requestId, conversationId: crypto.randomUUID(), prompt: 'Read only'
})
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent, expect.objectContaining({ requestId, type: status === 'completed' ? 'done' : 'error' })
))
}
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
expect.any(String), status, ...(status === 'failed' ? ['Runtime failed'] : [])
)
expect(notify).toHaveBeenCalledTimes(preference === 'default' || preference === true ? 1 : 0)
}
} finally {
await harness.dispose()
notify.mockRestore()
get.mockRestore()
await rm(directory, { recursive: true, force: true })
}
})
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,
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',
knowledgeLibraryIds: []
})
it.each(['completed', 'failed', 'cancelled'] as const)('persists reply duration through the production lease and %s path', async (status) => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const projectId = database.listProjects()[0]!.id
const conversationId = crypto.randomUUID()
const requestId = crypto.randomUUID()
let now = Date.now()
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
database.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Timing', updatedAt: now }, messages: [] }])
let finish!: () => void
const gate = new Promise<void>(resolve => { finish = resolve })
let started!: () => void
const ready = new Promise<void>(resolve => { started = resolve })
const run = vi.fn(async function* (request: AgentExecutionRequest) {
started()
await gate
if (status !== 'completed') throw new Error('Stopped')
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run },
undefined, undefined, false, undefined, undefined, undefined, false,
undefined, undefined, undefined, undefined, database)
try {
const event = trustedEvent(harness.webContents)
await harness.handler!(event, { requestId, conversationId, projectId, prompt: 'Run' })
await ready
now += 5000
const read = () => electronMocks.handlers.get(ipcChannels.tasksExecutionStats)!(event, { conversationId })
expect(await read()).toMatchObject({ durationMs: 5000, runningCount: 1, incomplete: false })
if (status === 'cancelled') {
harness.cancelHandler!(event, requestId)
await vi.waitFor(async () => expect(await read()).toMatchObject({ runningCount: 0 }))
now += 60_000
expect(await read()).toMatchObject({ durationMs: 5000, runningCount: 0 })
}
finish()
await vi.waitFor(() => expect(database.getTask(requestId).status).toBe(status))
now += 60_000
expect(await read()).toMatchObject({ durationMs: 5000, runningCount: 0 })
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.tasksExecutionStatsChanged)
} finally {
finish()
await harness.dispose()
clock.mockRestore()
database.close()
}
})
it.each(['answer', 'runtime-event'] as const)('pauses ordinary question timing until the last %s resolution and retains failed answers', async (resolution) => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const projectId = database.listProjects()[0]!.id
const conversationId = crypto.randomUUID()
const requestId = crypto.randomUUID()
const questionIds = [crypto.randomUUID(), crypto.randomUUID()]
let now = Date.now()
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
database.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Questions', updatedAt: now }, messages: [] }])
const deferred = () => {
let resolve!: () => void
const promise = new Promise<void>(complete => { resolve = complete })
return { promise, resolve }
}
const ready = deferred()
const finish = deferred()
const steps = questionIds.map(() => ({ release: deferred(), processed: deferred() }))
const respondToQuestion = vi.fn().mockRejectedValueOnce(new Error('Answer delivery failed')).mockResolvedValue(undefined)
const run = vi.fn(async function* () {
now += 2000
for (const questionId of questionIds) yield {
type: 'question' as const, requestId, questionId,
questions: [{ header: 'Input', question: 'Continue?', options: [], custom: true, multiple: false }]
}
ready.resolve()
for (const [index, questionId] of questionIds.entries()) {
await steps[index]!.release.promise
if (resolution === 'runtime-event') yield { type: 'question-resolved' as const, requestId, questionId }
steps[index]!.processed.resolve()
}
await finish.promise
yield { type: 'done' as const, requestId }
})
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run, respondToQuestion },
undefined, undefined, false, undefined, undefined, undefined, false,
undefined, undefined, undefined, undefined, database)
const read = () => database.getExecutionStats({ conversationId })
try {
const event = trustedEvent(harness.webContents)
await harness.handler!(event, { requestId, conversationId, projectId, prompt: 'Ask me' })
await ready.promise
expect(database.getTask(requestId).status).toBe('waiting_approval')
now += 60_000
expect(await read()).toMatchObject({ durationMs: 2000, runningCount: 0 })
const answer = electronMocks.handlers.get(ipcChannels.agentQuestionRespond)!
await expect(answer(event, { questionId: questionIds[0], answers: [['Yes']] })).rejects.toThrow('Answer delivery failed')
now += 60_000
expect(database.getTask(requestId).status).toBe('waiting_approval')
expect(await read()).toMatchObject({ durationMs: 2000, runningCount: 0 })
for (const [index, questionId] of questionIds.entries()) {
if (resolution === 'answer') await answer(event, { questionId, answers: index === 0 ? [['Yes']] : [] })
steps[index]!.release.resolve()
await steps[index]!.processed.promise
if (index === 0) {
now += 60_000
expect(database.getTask(requestId).status).toBe('waiting_approval')
expect(await read()).toMatchObject({ durationMs: 2000, runningCount: 0 })
}
}
expect(database.getTask(requestId).status).toBe('running')
now += 3000
finish.resolve()
await vi.waitFor(() => expect(database.getTask(requestId).status).toBe('completed'))
expect(await read()).toMatchObject({ durationMs: 5000, runningCount: 0 })
if (resolution === 'answer') expect(respondToQuestion).toHaveBeenLastCalledWith(questionIds[1], undefined)
} finally {
for (const step of steps) step.release.resolve()
finish.resolve()
await harness.dispose()
clock.mockRestore()
database.close()
}
})
it('binds the production image service and routes regeneration 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() }
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, 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', 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)!
await expect(Promise.resolve().then(() => cancel(event, { conversationId, operationId: original.id, prompt: 'injected' }))).rejects.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)
// Regeneration keeps the originating task so its usage satisfies the task foreign key.
expect(regenerated.requestId).toBe(original.requestId)
expect(regenerated.callId).not.toBe(original.callId)
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, 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', 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]
})
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('creates custom scheduled tasks without mode metadata', 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
})
await harness.dispose()
})
it.each([false, undefined])('heartbeat enabled gates block startup, timer claims, and manual runs (%s)', async (enabled) => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-23T12:00:00.000Z'))
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const config = database.createHeartbeatConfig({
name: 'Due review', scope: { kind: 'global' }, timezone: 'UTC',
recurrence: { type: 'daily', localTime: '11:00' }, enabled: true,
lookbackHours: 24, retentionDays: 30
}, new Date('2026-09-23T00:00:00.000Z'))
const claimDue = vi.spyOn(database, 'claimDueHeartbeats')
const claimNow = vi.spyOn(database, 'claimHeartbeatNow')
const activityRead = vi.spyOn(database, 'listSupervisionActivity')
const collect = vi.spyOn(database, 'buildHeartbeatInput')
const queueSchedules = vi.spyOn(database, 'queueDueSchedules')
const run = vi.fn()
const harness = createHarness({ capability: 'chat', run }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database, enabled)
try {
await vi.advanceTimersByTimeAsync(60_000)
expect(claimDue).not.toHaveBeenCalled()
expect(queueSchedules).toHaveBeenCalledTimes(3)
await expect(electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), {
id: config.id, idempotencyKey: crypto.randomUUID()
})).rejects.toThrow('智能督导未启用')
await expect(electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), {
trigger: 'manual', scope: { kind: 'global' },
timeRange: { from: '2026-09-22T00:00:00.000Z', to: '2026-09-23T12:00:00.000Z' }
})).rejects.toThrow('智能督导未启用')
expect(claimNow).not.toHaveBeenCalled()
await expect(electronMocks.handlers.get(ipcChannels.supervisionActivity)!(trustedEvent(harness.webContents), {})).resolves.toEqual([])
expect(activityRead).not.toHaveBeenCalled()
expect(collect).not.toHaveBeenCalled()
expect(run).not.toHaveBeenCalled()
expect(database.listHeartbeatRuns()).toEqual([])
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
})
it.each(['manual', 'scheduled'] as const)('a %s heartbeat waits for an active review and then only makes review requests', async (trigger) => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-23T12:00:00.000Z'))
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Evidence', updatedAt: Date.now() },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this decision', createdAt: Date.now() }] }])
let finishSupervisor!: () => void
const supervisorWait = new Promise<void>(resolve => { finishSupervisor = resolve })
let firstSupervisor = true
const run = vi.fn(async function* (request: AgentExecutionRequest, signal: AbortSignal) {
if (firstSupervisor) {
firstSupervisor = false
await supervisorWait
}
signal.throwIfAborted()
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 }
})
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
try {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorOrganizeTimeoutSeconds: 600 })
await vi.advanceTimersByTimeAsync(0)
const supervisor = electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), {
trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange: { from: new Date(Date.now() - 1000).toISOString(), to: new Date().toISOString() }
})
await vi.advanceTimersByTimeAsync(0)
expect(run).toHaveBeenCalledOnce()
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'New evidence', updatedAt: Date.now() },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review the next decision', createdAt: Date.now() }] }])
const config = database.createHeartbeatConfig({ name: 'Queued review', scope: { kind: 'global' }, timezone: 'UTC',
recurrence: { type: 'daily', localTime: '11:00' }, enabled: trigger === 'scheduled', lookbackHours: 24, retentionDays: 30 }, new Date('2026-09-23T00:00:00.000Z'))
const invoke = (key: string) => electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), { id: config.id, idempotencyKey: key })
const manual = trigger === 'manual' ? invoke('first') : undefined
const duplicate = trigger === 'manual' ? invoke('second') : undefined
await vi.advanceTimersByTimeAsync(trigger === 'scheduled' ? 30_000 : 0)
// The heartbeat finishes its own trigger at once; the review waits for the active one.
const [heartbeatRun] = database.listHeartbeatRuns(config.id)
expect(heartbeatRun).toMatchObject({ status: 'completed' })
expect(database.listSupervisionActivity().find(row => row.id === heartbeatRun!.id)?.status).toBe('running')
expect(run).toHaveBeenCalledOnce()
finishSupervisor()
await supervisor
await vi.waitFor(() => expect(database.listSupervisionActivity().find(row => row.id === heartbeatRun!.id)?.supervisionStatus).toBe('completed'))
if (manual) {
await expect(manual).resolves.toMatchObject({ id: heartbeatRun!.id })
await expect(duplicate).resolves.toMatchObject({ id: heartbeatRun!.id })
}
expect(run.mock.calls.some(([request]) => request.conversationId?.startsWith('heartbeat:'))).toBe(false)
expect(database.listHeartbeatEntries(config.id)).toEqual([])
// Manual re-analysis leaf + two conversation leaves + one navigation merge; no report call.
expect(run).toHaveBeenCalledTimes(4)
} finally { finishSupervisor(); await harness.dispose(); database.close(); vi.useRealTimers() }
})
it('times out a heartbeat-triggered review at the organize timeout and records it on the heartbeat', async () => {
vi.useFakeTimers()
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Evidence', updatedAt: Date.now() },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this decision', createdAt: Date.now() }] }])
const config = database.createHeartbeatConfig({ name: 'Review', scope: { kind: 'global' }, timezone: 'UTC',
recurrence: { type: 'daily', localTime: '11:00' }, enabled: false, lookbackHours: 24, retentionDays: 30 })
const releaseConversation = vi.fn(async () => undefined)
let signal: AbortSignal | undefined
const run = vi.fn(async function* (request: AgentExecutionRequest, inputSignal: AbortSignal) {
signal = inputSignal
await new Promise<void>(resolve => inputSignal.addEventListener('abort', () => resolve(), { once: true }))
inputSignal.throwIfAborted()
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
try {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorOrganizeTimeoutSeconds: 30 })
const pending = electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), { id: config.id, idempotencyKey: crypto.randomUUID() })
await vi.advanceTimersByTimeAsync(29_999)
expect(signal?.aborted).toBe(false)
await vi.advanceTimersByTimeAsync(1)
const heartbeatRun = await pending as { id: string }
expect(heartbeatRun).toMatchObject({ status: 'completed' })
expect(run).toHaveBeenCalledOnce()
expect(database.listSupervisionActivity().find(row => row.id === heartbeatRun.id)).toMatchObject({
status: 'failed', error: '监督者整理模型阶段超过 30 秒,已停止本次回顾;未保存结果' })
expect(database.listSupervisionResults()).toEqual([])
expect(database.listHeartbeatEntries()).toEqual([])
expect(releaseConversation).toHaveBeenCalledOnce()
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
})
it('shutdown aborts active supervision and a queued heartbeat review before runtime dispatch', async () => {
vi.useFakeTimers()
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Evidence', updatedAt: Date.now() },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this decision', createdAt: Date.now() }] }])
const config = database.createHeartbeatConfig({ name: 'Review', scope: { kind: 'global' }, timezone: 'UTC',
recurrence: { type: 'daily', localTime: '11:00' }, enabled: false, lookbackHours: 24, retentionDays: 30 })
let signal: AbortSignal | undefined
const releaseConversation = vi.fn(async () => undefined)
const run = vi.fn(async function* (request: AgentExecutionRequest, inputSignal: AbortSignal) {
signal = inputSignal
await new Promise<void>(resolve => inputSignal.addEventListener('abort', () => resolve(), { once: true }))
inputSignal.throwIfAborted()
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
try {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
const organize = electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), {
trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: new Date(Date.now() - 1000).toISOString(), to: new Date().toISOString() }
})
const rejected = expect(organize).rejects.toThrow('shutting down')
await vi.advanceTimersByTimeAsync(0)
const heartbeat = electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), { id: config.id, idempotencyKey: crypto.randomUUID() })
await vi.advanceTimersByTimeAsync(0)
expect(run).toHaveBeenCalledOnce()
await harness.dispose()
await rejected
await heartbeat
expect(signal?.aborted).toBe(true)
expect(run).toHaveBeenCalledOnce()
expect(releaseConversation).toHaveBeenCalledOnce()
expect(database.listSupervisionResults()).toEqual([])
expect(database.listHeartbeatEntries()).toEqual([])
} finally { database.close(); vi.useRealTimers() }
})
it('heartbeat enabled gates leave enabled startup and ticks idle without configured plans', async () => {
vi.useFakeTimers()
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const claimDue = vi.spyOn(database, 'claimDueHeartbeats')
const collect = vi.spyOn(database, 'buildHeartbeatInput')
const run = vi.fn()
const harness = createHarness({ capability: 'chat', run }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database, true)
try {
await vi.advanceTimersByTimeAsync(60_000)
expect(claimDue).toHaveBeenCalledTimes(3)
expect(database.listHeartbeatConfigs()).toEqual([])
expect(database.listHeartbeatRuns()).toEqual([])
expect(database.listSupervisionResults()).toEqual([])
expect(collect).not.toHaveBeenCalled()
expect(run).not.toHaveBeenCalled()
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
})
it('a suggest plan phrases rule-selected candidates once after the review and gates suggestion IPC', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const now = Date.now()
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Scheduling', updatedAt: now },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Page size is still undecided', createdAt: now }] }])
const config = database.createHeartbeatConfig({ name: 'Review', scope: { kind: 'global' }, timezone: 'UTC',
recurrence: { type: 'daily', localTime: '11:00' }, enabled: false, lookbackHours: 24, retentionDays: 30 })
const prompts: string[] = []
const run = vi.fn(async function* (request: AgentExecutionRequest) {
prompts.push(request.prompt)
const output = request.prompt.includes('CANDIDATES:')
? { suggestions: [{ ref: 'c1', title: 'Decide the page size', detail: 'Pick 50 or 100 next time.' }] }
: { summary: 'Review', changeDigest: '', openItems: ['Decide the page size'], events: [], entities: [], entityChanges: [], relations: [] }
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify(output) }
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
try {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
await electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), { id: config.id, idempotencyKey: crypto.randomUUID() })
expect(run).toHaveBeenCalledTimes(2)
expect(prompts[1]).toContain('CANDIDATES:')
expect(prompts[1]).not.toContain('Page size is still undecided')
const list = electronMocks.handlers.get(ipcChannels.supervisionSuggestions)!
const [suggestion] = await list(trustedEvent(harness.webContents), {}) as Array<{ id: string; title: string; kind: string }>
expect(suggestion).toMatchObject({ kind: 'open_item', title: 'Decide the page size' })
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: false })
await expect(list(trustedEvent(harness.webContents), {})).resolves.toEqual([])
await expect(electronMocks.handlers.get(ipcChannels.supervisionSuggestionAction)!(trustedEvent(harness.webContents),
{ id: suggestion!.id, action: 'accept' })).rejects.toThrow('Supervisor is disabled')
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
await expect(electronMocks.handlers.get(ipcChannels.supervisionSuggestionAction)!(trustedEvent(harness.webContents),
{ id: suggestion!.id, action: 'accept' })).resolves.toMatchObject({ status: 'accepted', taskId: expect.any(String) })
} finally { await harness.dispose(); database.close() }
})
it('heartbeat enabled gates follow live toggles and suppress suggestions after an in-flight review', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-23T12:00:00.000Z'))
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'New input', updatedAt: Date.now() },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this new decision', createdAt: Date.now() }] }])
database.createHeartbeatConfig({
name: 'Due review', scope: { kind: 'global' }, timezone: 'UTC',
recurrence: { type: 'daily', localTime: '11:00' }, enabled: true,
lookbackHours: 24, retentionDays: 30
}, new Date('2026-09-23T00:00:00.000Z'))
const claimDue = vi.spyOn(database, 'claimDueHeartbeats')
let finish!: () => void
const pending = new Promise<void>(resolve => { finish = resolve })
const run = vi.fn(async function* (request: AgentExecutionRequest) {
await pending
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify(
{ summary: 'Review', changeDigest: '', openItems: ['Decide the page size'], events: [], entities: [], entityChanges: [], relations: [] }) }
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness({ capability: 'chat', run }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database, false)
try {
await vi.advanceTimersByTimeAsync(0)
expect(claimDue).not.toHaveBeenCalled()
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
await vi.advanceTimersByTimeAsync(30_000)
expect(claimDue).toHaveBeenCalledOnce()
expect(run).toHaveBeenCalledOnce()
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: false })
finish()
await vi.advanceTimersByTimeAsync(30_000)
// An already running review may finish, but no suggestion step follows once disabled.
expect(database.listHeartbeatRuns()).toEqual([expect.objectContaining({ status: 'completed' })])
expect(database.listSupervisionResults()).toHaveLength(1)
expect(database.supervisionSuggestions().list('all', 50, 0)).toEqual([])
expect(run).toHaveBeenCalledOnce()
expect(claimDue).toHaveBeenCalledOnce()
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
const config = database.listHeartbeatConfigs()[0]!
// Nothing changed since the last review: no model call.
await expect(electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), {
id: config.id, idempotencyKey: crypto.randomUUID()
})).resolves.toMatchObject({ status: 'no_change' })
expect(run).toHaveBeenCalledOnce()
} finally { finish(); await harness.dispose(); database.close(); vi.useRealTimers() }
})
it('production supervision IPC cancels the runtime and holds admission until conversation cleanup settles', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const now = Date.now()
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Cancel review', updatedAt: now },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this', createdAt: now }] }])
let entered!: () => void, cleaning!: () => void, finish!: () => void
const started = new Promise<void>(resolve => { entered = resolve })
const cleanupStarted = new Promise<void>(resolve => { cleaning = resolve })
const cleanup = new Promise<void>(resolve => { finish = resolve })
const run = vi.fn(async function* (_input: AgentExecutionRequest, signal: AbortSignal) {
entered()
await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
signal.throwIfAborted()
yield { type: 'done' as const, requestId: _input.requestId }
})
const releaseConversation = vi.fn(async () => { cleaning(); await cleanup })
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
try {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
const event = trustedEvent(harness.webContents)
const request = { trigger: 'manual', scope: { kind: 'global' }, timeRange: {
from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString()
} }
const pending = electronMocks.handlers.get(ipcChannels.supervisionRun)!(event, request)
await started
const runId = database.listSupervisionActivity()[0]!.id
const input = { runId }
await expect(electronMocks.handlers.get(ipcChannels.supervisionCancel)!({ sender: {} }, input)).rejects.toThrow()
await expect(electronMocks.handlers.get(ipcChannels.supervisionCancel)!(event, { runId: 'invalid' })).rejects.toThrow()
const cancellation = electronMocks.handlers.get(ipcChannels.supervisionCancel)!(event, input)
await cleanupStarted
expect(database.listSupervisionActivity()[0]!.status).toBe('cancelled')
expect(await electronMocks.handlers.get(ipcChannels.supervisionExecution)!(event)).toEqual({ active: true, runId, stopping: 'cancelled' })
await expect(electronMocks.handlers.get(ipcChannels.supervisionRun)!(event, request)).rejects.toThrow('SUPERVISION_REVIEW_BUSY')
await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!(event, input)).rejects.toThrow('SUPERVISION_REVIEW_BUSY')
finish()
await cancellation
await expect(pending).resolves.toMatchObject({ status: 'cancelled' })
expect(await electronMocks.handlers.get(ipcChannels.supervisionExecution)!(event)).toMatchObject({ active: false })
await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!(event, input)).rejects.toThrow('SUPERVISION_REVIEW_CANCELLED')
expect(database.listSupervisionResults()).toEqual([])
expect(run).toHaveBeenCalledTimes(1)
expect(releaseConversation).toHaveBeenCalledTimes(1)
} finally { finish(); await harness.dispose(); database.close() }
})
it('routes native clients through validated owner-scoped IPC and cleans up the window', async () => {
const coordinator = {
open: vi.fn(async () => ({ kind: 'browser' as const, serviceId: 'native-service' })),
get: vi.fn(async () => ({ serviceId: 'native-service' })),
stop: vi.fn(async () => {}),
closeOwner: vi.fn(async () => {})
}
const harness = createHarness({}, undefined, undefined, false, undefined,
undefined, undefined, false, undefined, undefined, undefined, undefined, undefined, false, undefined, coordinator)
const event = trustedEvent(harness.webContents)
const input = { conversationId: '00000000-0000-4000-8000-000000000401' }
const open = electronMocks.handlers.get(ipcChannels.runtimeNativeClientOpen)!
const get = electronMocks.handlers.get(ipcChannels.runtimeNativeClientGet)!
const stop = electronMocks.handlers.get(ipcChannels.runtimeNativeClientStop)!
await expect(open(event, input)).resolves.toEqual({ kind: 'browser', serviceId: 'native-service' })
expect(coordinator.open).toHaveBeenCalledWith(9, input.conversationId)
await expect(get(event, input)).resolves.toEqual({ serviceId: 'native-service' })
expect(coordinator.get).toHaveBeenCalledWith(9, input.conversationId)
await stop(event, { serviceId: 'native-service' })
expect(coordinator.stop).toHaveBeenCalledWith(9, 'native-service')
await expect(open(event, { ...input, runtime: 'continue' })).rejects.toThrow()
await expect(open({ ...event, sender: { id: 99 } }, input)).rejects.toThrow()
await expect(stop(event, { serviceId: '' })).rejects.toThrow()
expect(coordinator.open).toHaveBeenCalledTimes(1)
expect(harness.webContents.once).toHaveBeenCalledWith('destroyed', expect.any(Function))
await harness.dispose()
expect(coordinator.closeOwner).toHaveBeenCalledWith(9)
})
it('production supervision IPC pauses in-flight work and resumes saved batches without replaying them', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const now = Date.now()
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Resume review', updatedAt: now },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: '中🧭'.repeat(1500), createdAt: now }] }])
let calls = 0, entered!: () => void
const summary = 'Retained review detail. '.repeat(150)
const second = new Promise<void>(resolve => { entered = resolve })
const run = vi.fn(async function* (input: AgentExecutionRequest, signal: AbortSignal) {
if (++calls === 2) {
entered()
await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
signal.throwIfAborted()
}
const navigation = input.prompt.includes('These inputs are navigation summaries')
const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as Array<{ id: string }>
if (navigation) expect(input.prompt).toContain('"entities":[]')
else expect(input.prompt).toContain('KNOWN ENTITIES is empty. Every entity is new; omit candidateRef')
yield { type: 'text' as const, requestId: input.requestId, delta: JSON.stringify({ summary, changeDigest: '', openItems: [], events: [],
entities: navigation ? [] : ['Atlas', 'Beacon'].map((label, index) => ({ id: label, label, description: label,
persistedId: index ? null : '', sourceReferenceIds: [evidence[0]!.id] })), entityChanges: [], relations: [] }) }
yield { type: 'done' as const, requestId: input.requestId }
})
const releaseConversation = vi.fn(async () => undefined)
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
try {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelConcurrency: 1,
supervisionReview: { pageSize: 1, batchCharacters: 1000, batchMessages: 1, executionSeconds: 300 } })
const event = trustedEvent(harness.webContents)
const pending = electronMocks.handlers.get(ipcChannels.supervisionRun)!(event, { trigger: 'manual', scope: { kind: 'global' }, timeRange: {
from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString()
} })
await Promise.race([second, Promise.resolve(pending).then(() => { throw new Error('Review ended before its second batch') })])
const activity = database.listSupervisionActivity()[0]!
expect(activity.reviewProgress).toMatchObject({ batches: 1, complete: false })
const input = { runId: activity.id }
await electronMocks.handlers.get(ipcChannels.supervisionPause)!(event, input)
await expect(pending).resolves.toMatchObject({ status: 'paused' })
const saved = database.supervisionReviewStore().batches(activity.id)[0]!
expect(saved.output.entities).toHaveLength(2)
for (const entity of saved.output.entities) expect(entity).not.toHaveProperty('persistedId')
await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!(event, input)).resolves.toMatchObject({ status: 'completed', coverage: { remainingSources: 0 } })
expect(database.supervisionReviewStore().batches(activity.id)[0]).toEqual(saved)
const published = database.listSupervisionResults()[0]!
expect(published.summary).toBe(summary)
expect(saved.output.summary).toBe(summary)
const source = database.getSupervisionSource(published.sourceId!)!
const context = electronMocks.handlers.get(ipcChannels.supervisionSourceContext)!(event, { sourceId: source.id })
expect(await context).toMatchObject({ content: source.content,
messageId: JSON.parse(String(source.locatorJson)).messageId })
expect(releaseConversation).toHaveBeenCalledTimes(calls)
expect(database.listSupervisionActivity()).toHaveLength(1)
await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!({ sender: {} }, input)).rejects.toThrow()
await expect(Promise.resolve().then(() => electronMocks.handlers.get(ipcChannels.supervisionBatches)!(event, { ...input, limit: 21 }))).rejects.toThrow()
} finally { await harness.dispose(); database.close() }
})
it('routes Supervisor overview, graph and stories IPC through the real readonly worker without Main fallback', async () => {
const directory = await mkdtemp(join(tmpdir(), 'supervision-ipc-worker-'))
const worker = join(directory, 'worker.cjs')
execFileSync(process.execPath, ['-e', `require('esbuild').buildSync(${JSON.stringify({
entryPoints: [join(process.cwd(), 'src/main/readonly-query-worker.ts')], outfile: worker,
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent'
})})`], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } })
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
const at = '2026-10-01T12:00:00.000Z'
database.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: at, to: at } },
evidence: [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: 'Source', content: 'Full body', occurredAt: at }],
output: { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } })
const overview = database.listSupervisionResults()
const resultId = overview[0]!.id
const sourceId = overview[0]!.sourceId!
const harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database)
try {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
const event = trustedEvent(harness.webContents)
const requests = [[ipcChannels.supervisionOverview, {}], [ipcChannels.supervisionGraph, { resultId }],
[ipcChannels.supervisionStories, { scope: { kind: 'global' } }]] as const
const reader = new ReadonlyQueryReader('assistant', join(directory, 'assistant.sqlite'), worker)
storageReaders.set(database, reader)
const reads = [vi.spyOn(database, 'listSupervisionResults'), vi.spyOn(database, 'getSupervisionGraph'), vi.spyOn(database, 'supervisionStories')]
await expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(event, {})).resolves.toEqual(overview)
await expect(electronMocks.handlers.get(ipcChannels.supervisionGraph)!(event, { resultId })).resolves.toMatchObject({
sources: [{ id: sourceId, title: 'Source', occurred_at: at }] })
await expect(electronMocks.handlers.get(ipcChannels.supervisionStories)!(event, { scope: { kind: 'global' } })).resolves.toEqual({
stories: [], experiences: [], unassigned: 0, canUndo: false })
expect(await electronMocks.handlers.get(ipcChannels.supervisionSource)!(event, { sourceId })).toMatchObject({ content: 'Full body' })
await reader.terminateWorkerForTest()
for (const [channel, input] of requests) await expect(electronMocks.handlers.get(channel)!(event, input)).rejects.toThrow('backing off')
for (const read of reads) expect(read).not.toHaveBeenCalled()
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: false })
await expect(electronMocks.handlers.get(ipcChannels.supervisionStories)!(event, { scope: { kind: 'global' } })).resolves.toEqual({
stories: [], experiences: [], unassigned: 0, canUndo: false })
} finally {
await harness.dispose(); await storageReaders.get(database)?.close(); storageReaders.delete(database); database.close()
await rm(directory, { recursive: true, force: true, maxRetries: 10 })
}
}, 60_000)
it('scopes supervision continuation context to source-linked graph evidence', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const occurredAt = '2026-09-22T10:00:00.000Z'
database.saveSupervisionResult({
request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: occurredAt, to: occurredAt } },
evidence: ['selected', 'unrelated', 'unlinked'].map((id) => ({
id, sourceType: 'conversation', sourceId: `${id}-conversation`, title: `${id} source`, content: `${id} snapshot`, occurredAt
})),
output: {
summary: 'Unrelated whole-result summary', changeDigest: 'Unrelated global digest', openItems: ['Unrelated open item'],
entities: ['Atlas', 'Beacon', 'Other'].map((id) => ({
id, label: id, description: `${id} actual description`, sourceReferenceIds: [id === 'Other' ? 'unrelated' : 'selected']
})),
events: [
{ title: 'Linked decision', description: 'Linked event actual description', occurredAt, eventType: 'decision', entityIds: ['Atlas', 'Beacon'], sourceReferenceIds: ['selected'] },
{ title: 'Unrelated event', description: 'Unrelated event description', occurredAt, eventType: 'discussion', entityIds: ['Other'], sourceReferenceIds: ['unrelated'] }
],
entityChanges: [],
relations: [
{ fromEntityId: 'Atlas', toEntityId: 'Beacon', relationType: 'depends-on', reason: 'Linked relation actual reason', sourceReferenceIds: ['selected'] },
{ fromEntityId: 'Atlas', toEntityId: 'Other', relationType: 'related', reason: 'Unrelated relation reason', sourceReferenceIds: ['unrelated'] }
]
}
})
const harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database)
try {
const resultId = database.listSupervisionResults()[0]!.id
const graph = database.getSupervisionGraph({ resultId }) as import('../shared/supervision-contracts').SupervisionGraphView
const getGraph = vi.spyOn(database, 'getSupervisionGraph')
const handler = electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!
const sourceId = graph.sources.find((source) => source.title === 'selected source')!.id
const context = await handler(trustedEvent(harness.webContents), { resultId, sourceId }) as { prompt: string; summary: string; source: unknown }
expect(getGraph).toHaveBeenCalledWith({ resultId })
expect(context.source).toEqual({ title: 'selected source', sourceType: 'conversation', sourceId: 'selected-conversation', content: 'selected snapshot', occurredAt })
for (const evidence of ['Linked decision', 'Linked event actual description', 'Atlas actual description', 'Beacon actual description', 'depends-on', 'Linked relation actual reason']) {
expect(context.summary).toContain(evidence)
expect(context.prompt).toContain(evidence)
}
expect(context.prompt).toContain('selected snapshot')
for (const unrelated of ['Unrelated whole-result summary', 'Unrelated global digest', 'Unrelated open item', 'Unrelated event', 'Other actual description', 'Unrelated relation reason', 'unrelated snapshot', 'unlinked snapshot']) {
expect(context.summary).not.toContain(unrelated)
expect(context.prompt).not.toContain(unrelated)
}
const unlinkedSourceId = graph.sources.find((source) => source.title === 'unlinked source')!.id
const unlinked = await handler(trustedEvent(harness.webContents), { resultId, sourceId: unlinkedSourceId }) as { prompt: string; summary: string }
expect(unlinked.summary).toBe('')
expect(unlinked.prompt).toBe('请基于以下监督回顾继续讨论。\n\n来源:unlinked source\nunlinked snapshot')
} finally { await harness.dispose(); database.close() }
})
it('routes supervisor model selection through the real direct runtime to a saved connection', async () => {
const calls: Array<{ model: string; tools?: unknown; temperature?: number }> = []
const server = createServer(async (request, response) => {
let body = ''
for await (const chunk of request) body += String(chunk)
calls.push(JSON.parse(body))
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(`data: ${JSON.stringify({ choices: [{ delta: { content: JSON.stringify({ summary: 'Connection verified', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`)
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as { port: number }
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const now = Date.now()
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Review', updatedAt: now },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'A saved decision', createdAt: now }] }])
const harness = createHarness({ runtimeId: 'opencode', capability: 'chat', run: vi.fn() }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
runtimeFactoryMocks.createModelProfileRuntime.mockImplementation(actual.createModelProfileRuntime)
const profile = { id: crypto.randomUUID(), name: 'Review connection',
baseUrl: `http://127.0.0.1:${address.port}/v1`, modelName: 'review-selected', protocol: 'openai-chat-completions', authentication: 'none',
requestHeaders: {}, requestBody: { temperature: 0.25 } }
const fallback = { ...profile, id: crypto.randomUUID(), modelName: 'app-default' }
harness.getResolvedSettings.mockResolvedValue({ ...defaultRuntimeSettings, provider: 'opencode', modelProfiles: [profile, fallback], defaultModelProfileId: fallback.id })
const handler = electronMocks.handlers.get(ipcChannels.supervisionRun)!
const input = { trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange: {
from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString()
} }
try {
for (const supervisorModelProfileId of [profile.id, null]) {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId })
await expect(handler(trustedEvent(harness.webContents), input)).resolves.toMatchObject({ status: 'completed' })
}
expect(calls.map(call => call.model)).toEqual(['review-selected', 'app-default'])
expect(calls.every(call => !call.tools && call.temperature === 0.25)).toBe(true)
for (const invalid of [undefined, { ...profile, protocol: 'openai-images-generations' }, { ...profile, authentication: 'api-key', apiKey: undefined }]) {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId: profile.id })
harness.getResolvedSettings.mockResolvedValue({ modelProfiles: invalid ? [invalid, fallback] : [fallback], defaultModelProfileId: fallback.id })
await expect(handler(trustedEvent(harness.webContents), input)).rejects.toThrow(/不可用|API Key/)
}
expect(calls).toHaveLength(2)
} finally {
await harness.dispose()
database.close()
server.closeAllConnections()
await new Promise<void>(resolve => server.close(() => resolve()))
runtimeFactoryMocks.createModelProfileRuntime.mockReset()
}
})
it('requires a directory through project creation IPC while preserving blank-path updates', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database)
try {
const event = trustedEvent(harness.webContents)
const create = electronMocks.handlers.get(ipcChannels.projectsCreate)!
const input = { name: 'Directory project', description: '', rootPath: '' }
const initialCount = database.listProjects().length
for (const rootPath of ['', ' ']) {
await expect(Promise.resolve().then(() => create(event, { ...input, rootPath }))).rejects.toThrow()
}
expect(database.listProjects()).toHaveLength(initialCount)
const created = await create(event, { ...input, rootPath: ` ${process.cwd()} ` }) as AssistantProject
expect(database.getProject(created.id).rootPath).toBe(process.cwd())
database.updateProject(created.id, input)
await electronMocks.handlers.get(ipcChannels.projectsUpdate)!(event, {
projectId: created.id, input: { ...input, name: 'Renamed historical project' }
})
expect(database.getProject(created.id)).toMatchObject({ name: 'Renamed historical project', rootPath: '' })
} finally {
await harness.dispose()
database.close()
}
})
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() })
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' })
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' })
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' })
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, 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 } }
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
const result = await electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), input) as import('./assistant/supervisor-service').StoredSupervisionResult
expect(result.request).toEqual(input)
const batches = await electronMocks.handlers.get(ipcChannels.supervisionBatches)!(trustedEvent(harness.webContents), { runId: result.runId }) as import('../shared/supervision-review-contracts').SupervisionReviewBatch[]
const evidence = batches.flatMap(batch => batch.evidence)
expect(result.coverage).toMatchObject({ complete: true, remainingSources: 0, sources: 4 })
expect(evidence.filter(item => item.sourceType === 'conversation').map(item => item.content)).toEqual(['At start', 'At end'])
expect(evidence.filter(item => item.sourceType === 'task')).toEqual(expect.arrayContaining([
expect.objectContaining({ sourceId: oldTask.id, occurredAt: to }),
expect.objectContaining({ sourceId: newTask.id, occurredAt: from })
]))
expect(evidence.filter(item => item.sourceType === 'task')).toHaveLength(2)
expect(run.mock.calls[0]![0].prompt).toContain(memory.content)
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)
const activity = electronMocks.handlers.get(ipcChannels.supervisionActivity)!
await expect(activity(trustedEvent(harness.webContents), { limit: 50, offset: 0 })).resolves.toEqual([
expect.objectContaining({ status: 'completed', resultId: overview[0]!.id, scope, trigger: 'manual' })
])
await expect(activity(trustedEvent(harness.webContents), { limit: 101 })).rejects.toThrow()
await expect(activity(trustedEvent(harness.webContents), { configId: 'unrelated-plan' })).resolves.toEqual([])
await expect(activity(trustedEvent(harness.webContents), { configId: '' })).rejects.toThrow()
await expect(activity({ sender: {} }, {})).rejects.toThrow()
await expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id })).resolves.toEqual(overview)
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(evidence.length)
expect(graph).toHaveProperty('attention', [{ start: from, turns: 2, characters: 14 }])
await expect(electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).rejects.toThrow('不匹配')
await expect(Promise.resolve().then(() => electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!(trustedEvent(harness.webContents), { resultId: 'wrong-result', sourceId: overview[0]!.sourceId }))).rejects.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).toHaveBeenCalledTimes(3)
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
})
it.each([
['throw', undefined], ['return', undefined], ['incomplete', undefined],
['throw', 30], ['return', 600], ['provider', 30], ['cleanup', 30]
] as const)('rejects supervision timeout or incomplete streams without persisting (%s, %s)', async (ending, configuredTimeout) => {
vi.useFakeTimers()
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Evidence', updatedAt: Date.now() },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this decision', createdAt: Date.now() }] }])
let first = true
let providerFailures = ending === 'provider' ? 2 : 0
const releaseConversation = vi.fn(async () => undefined)
if (ending === 'cleanup') releaseConversation.mockRejectedValueOnce(new Error('Release failed'))
const run = vi.fn(async function* (request: AgentExecutionRequest, signal: AbortSignal) {
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify({ summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
if (providerFailures > 0) {
providerFailures--
first = false
throw new Error('Provider HTTP 429: retry later')
}
if (first) {
first = false
if (ending === 'incomplete') return
if (ending !== 'cleanup') await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
if (ending === 'throw') throw new DOMException('This operation was aborted', 'AbortError')
}
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
try {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, heartbeatReportTimeoutSeconds: 60, supervisorOrganizeTimeoutSeconds: configuredTimeout })
const input = { trigger: 'manual', scope: { kind: 'global' }, timeRange: {
from: new Date(Date.now() - 1000).toISOString(), to: new Date().toISOString()
} }
const invoke = () => electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), input)
const timeoutSeconds = configuredTimeout ?? 240
const error = ending === 'incomplete' ? '监督者模型未报告完成' : ending === 'provider' ? 'Provider HTTP 429: retry later' : ending === 'cleanup' ? 'Release failed' : `监督者整理模型阶段超过 ${timeoutSeconds} 秒,已停止本次回顾;未保存结果`
const rejected = expect(invoke()).rejects.toThrow(error)
await vi.advanceTimersByTimeAsync(timeoutSeconds * 1000 - 1)
if (ending === 'throw' || ending === 'return') expect(database.listSupervisionActivity()[0]?.status).toBe('running')
await vi.advanceTimersByTimeAsync(1)
await rejected
expect(database.listSupervisionResults()).toEqual([])
expect(database.listSupervisionActivity()).toEqual([expect.objectContaining({ status: 'failed', error })])
expect(releaseConversation).toHaveBeenCalledTimes(ending === 'provider' ? 2 : 1)
await invoke()
expect(database.listSupervisionResults()).toHaveLength(1)
expect(releaseConversation).toHaveBeenCalledTimes(ending === 'provider' ? 3 : 2)
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
})
it('skips unchanged sources for heartbeat and default manual reviews while explicit reanalysis reprocesses history', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const now = Date.now()
const conversationId = crypto.randomUUID()
const messageId = crypto.randomUUID()
const write = (content: string) => database.saveLocalConversations([{
header: { id: conversationId, projectId: database.listProjects()[0]!.id, title: 'Atlas', updatedAt: now },
messages: [{ id: messageId, role: 'user', state: 'complete', createdAt: now, content }]
}])
write('Atlas uses SQLite')
const run = vi.fn(async function* (request: AgentExecutionRequest) {
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify({ summary: 'Atlas reviewed', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run }, undefined, undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database)
try {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
const input = { trigger: 'heartbeat', scope: { kind: 'global' }, timeRange: {
from: new Date(now - 1000).toISOString(), to: new Date(now + 1000).toISOString()
} }
const invoke = (request: Record<string, unknown> = input) => electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), request)
await invoke()
await expect(invoke()).resolves.toMatchObject({ status: 'no_change', evidence: [] })
expect(run).toHaveBeenCalledTimes(1)
write('Atlas now uses WAL')
await invoke()
expect(run).toHaveBeenCalledTimes(2)
expect(run.mock.calls[1]![0].prompt).toContain('Atlas now uses WAL')
await expect(invoke({ ...input, trigger: 'manual' })).resolves.toMatchObject({ status: 'no_change' })
expect(run).toHaveBeenCalledTimes(2)
await invoke({ ...input, trigger: 'manual', reanalyze: true })
await invoke({ ...input, trigger: 'manual', reanalyze: true })
expect(run).toHaveBeenCalledTimes(4)
// Re-analysis never moves shared progress, so the heartbeat still sees nothing new.
await expect(invoke()).resolves.toMatchObject({ status: 'no_change' })
expect(database.listSupervisionResults()).toHaveLength(4)
expect(database.listSupervisionActivity().filter((row) => row.status === 'no_change')).toHaveLength(3)
} finally { await harness.dispose(); database.close() }
})
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(), runtimeSelection: { provider: 'opencode' } }, messages: [] }])
const harness = createHarness({ capability: 'chat' }, undefined, 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, 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 {
await expect(Promise.resolve().then(() => preview(event, input))).rejects.toThrow('不属于所选知识库')
await expect(Promise.resolve().then(() => preview(event, { ...input, entityId: crypto.randomUUID() }))).rejects.toThrow('不属于所选知识库')
await expect(Promise.resolve().then(() => preview(event, { ...input, operation: 'create-entity', libraryId: crypto.randomUUID() }))).rejects.toThrow('知识库不存在')
const binding = vi.spyOn(database.externalStore, 'hasBinding').mockReturnValue(true)
await expect(Promise.resolve().then(() => preview(event, { ...input, operation: 'create-entity' }))).rejects.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)
await expect(Promise.resolve().then(() => commit(event, { previewId: first.previewId }))).rejects.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 })
await expect(Promise.resolve().then(() => commit(event, { previewId: second.previewId }))).rejects.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()
let timingNow = Date.now()
const timingClock = vi.spyOn(Date, 'now').mockImplementation(() => timingNow)
database.saveLocalConversations([{
header: { id: conversationId, title: 'Immediate', updatedAt: Date.now() },
messages: []
}])
const run = vi.fn(async function* (request: AgentExecutionRequest) {
timingNow += 3000
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, 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'
}
await expect(Promise.resolve().then(() => create(event, input))).rejects.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
)
await vi.waitFor(() => 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,
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)
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 3000, runningCount: 0, incomplete: false })
} finally {
await harness.dispose()
timingClock.mockRestore()
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 },
_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).not.toHaveProperty('workMode')
expect(authorize).toBeUndefined()
yield { requestId: request.requestId, type: 'tool', callId: 'scheduled-write',
name: 'workspace.write', state: 'completed', summary: 'Updated status file' } as const
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: '汇总状态',
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)
expect(harness.assistantDatabase.createTask).not.toHaveBeenCalled()
await vi.waitFor(() => 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, history: [{ role: 'user', content: 'Earlier conversation' }]
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.completeTaskScheduleRun
).toHaveBeenCalledWith(runId)
)
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(taskId, 'running')
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: '执行任务',
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 vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch, expect.objectContaining({ item: queueItem })
))
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: runId, conversationId, queueItemId: queueItem.id,
prompt: schedule.prompt
})
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: '第一条',
knowledgeLibraryIds: []
}
)
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000723',
conversationId,
prompt: '第二条',
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: '第三条',
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,
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: '旧请求',
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: '替换请求',
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: '替换请求',
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: '不能并发',
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: '清理后重试',
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.each([false, true])('owns a dedicated tool-free synthesis runtime and disposes it on failure=%s', async (fail) => {
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
runtimeFactoryMocks.createDefaultModelRuntime.mockImplementation(actual.createDefaultModelRuntime)
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
const body = JSON.parse(String(init?.body))
expect(body.tools).toBeUndefined()
expect(body.tool_choice).toBeUndefined()
return new Response('data: {"choices":[{"delta":{"content":"Combined answer"},"finish_reason":null}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
{ headers: { 'content-type': 'text/event-stream' } })
})
vi.stubGlobal('fetch', fetcher)
let disposeSynthesis: ReturnType<typeof vi.spyOn> | undefined
const subagents = {
run: vi.fn<SubagentService['run']>(async () => ({ childTaskId: crypto.randomUUID(), output: 'Specialist result' })),
synthesize: vi.fn(async (...args: Parameters<SubagentService['synthesize']>) => {
const [request, prompt, signal, synthesisRuntime, onModelUsage] = args
expect(prompt).toContain('Specialist result')
disposeSynthesis = vi.spyOn(synthesisRuntime, 'dispose')
const events = []
for await (const event of synthesisRuntime.run({ ...request, prompt }, signal)) events.push(event)
expect(events).toContainEqual(expect.objectContaining({ type: 'text', delta: 'Combined answer' }))
expect(events).toContainEqual(expect.objectContaining({ type: 'done' }))
onModelUsage!({ requestId: args[0].requestId, type: 'model-usage', callId: 'synthesis-usage',
runtime: 'model', provider: 'openai', model: 'test-model', inputTokens: 10, outputTokens: 5,
cacheReadTokens: 0, cacheWriteTokens: 0 })
if (fail) throw new Error('Synthesis failed')
return 'Combined answer'
}),
dispose: vi.fn(async () => undefined)
}
const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run: vi.fn() }
const harness = createHarness(runtime, undefined, subagents)
const settings = { ...runtimeUpdateFixture(process.cwd()).publicSettings, modelAuthentication: 'none' as const }
harness.getResolvedSettings.mockResolvedValue(settings)
harness.assistantDatabase.listExperts.mockReturnValue([
{ id: crypto.randomUUID(), name: 'First', enabled: true },
{ id: crypto.randomUUID(), name: 'Second', enabled: true }
])
const requestId = crypto.randomUUID()
try {
await harness.handler!(trustedEvent(harness.webContents), {
requestId, conversationId: 'synthesis-test', prompt: 'Compare the analyses', teamMode: true
})
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId, fail ? 'failed' : 'completed', ...(fail ? ['Synthesis failed'] : [])))
expect(subagents.run).toHaveBeenCalledTimes(2)
for (const [input] of subagents.run.mock.calls) {
expect(input).not.toHaveProperty('authorize')
}
expect(runtime.run).not.toHaveBeenCalled()
expect(runtimeFactoryMocks.createDefaultModelRuntime).toHaveBeenCalledExactlyOnceWith(process.cwd(), settings)
expect(subagents.synthesize).toHaveBeenCalledOnce()
expect(fetcher).toHaveBeenCalledOnce()
expect(harness.assistantDatabase.upsertModelUsageCall).toHaveBeenCalledWith(
expect.objectContaining({ callId: 'synthesis-usage' }))
expect(disposeSynthesis).toHaveBeenCalledOnce()
} finally {
await harness.dispose()
runtimeFactoryMocks.createDefaultModelRuntime.mockReset()
vi.unstubAllGlobals()
}
})
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, 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, 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', 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,
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',
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)
harness.getResolvedSettings.mockResolvedValue({
provider: 'opencode', defaultModelProfileId: selection.profileId, opencodeBaseUrl: '',
modelProfiles: [{
id: selection.profileId, name: 'Remote', modelName: 'remote', baseUrl: 'https://models.example/v1',
protocol: 'openai-chat-completions', authentication: 'none'
}]
})
const project = harness.assistantDatabase.getProject(projectId)
harness.assistantDatabase.getProject.mockReturnValue({
...project,
runtimeSelection: { provider: 'opencode', model: { kind: 'profile', profileId: selection.profileId } }
})
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 workspace and delegation tools in the capability 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'
})
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('captures project conversations before deletion and waits for attachment reference release', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const project = database.createProject({ name: 'Delete refs', rootPath: process.cwd(), description: '' })
const conversationId = crypto.randomUUID()
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Attached', updatedAt: Date.now() }, messages: [] }])
const harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database)
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
const deleteProject = vi.fn(async () => { await gate })
Object.assign(harness.contextManager, { assets: { deleteProject }, activeContextIds: () => ['active-draft'], cancelUnavailableImport: vi.fn(async () => {}) })
try {
let completed = false
const pending = Promise.resolve(electronMocks.handlers.get(ipcChannels.projectsDelete)!(trustedEvent(harness.webContents), {
projectId: project.id, confirmation: project.name
})).then(() => { completed = true })
await vi.waitFor(() => expect(deleteProject).toHaveBeenCalledWith([conversationId], ['active-draft']))
expect(database.listConversationSummaries().some(conversation => conversation.id === conversationId)).toBe(false)
expect(completed).toBe(false)
release()
await pending
expect(completed).toBe(true)
} finally {
release()
await harness.dispose()
database.close()
}
})
it('durably commits remote text at its checkpoint 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
const checkpoint = {
requestId: request.requestId,
type: 'remote-semantic-checkpoint',
remoteProvenance: { ...provenance, eventIndex: 1 }
} as const
yield remoteText
yield checkpoint
// The Agent may ACK the entry here: text and checkpoint must already
// be committed, together, in one batch.
expect(
harness.assistantDatabase.appendRemoteTaskEventsOnce
).toHaveBeenCalledOnce()
expect(
harness.assistantDatabase.appendRemoteTaskEventsOnce
).toHaveBeenLastCalledWith([
{
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
}
},
expect.objectContaining({
eventIndex: 1,
kind: 'remote-semantic-checkpoint'
})
])
resumed('first')
// Reconnect replay of the same transcript entry.
yield remoteText
yield checkpoint
resumed('duplicate')
yield {
requestId: request.requestId,
type: 'done',
remoteProvenance: {
...provenance,
semanticSequence: '2'
}
} as const
}
}
const fixture = createManagedSshHarness(selectedRuntime)
const { harness } = fixture
let releaseCommit!: () => void
const commitGate = new Promise<void>(resolve => { releaseCommit = resolve })
let commitStarted = false
beforeStorageCall.set(harness.assistantDatabase, async method => {
if (method === 'appendRemoteTaskEventsOnce' && !commitStarted) {
commitStarted = true
await commitGate
}
})
harness.assistantDatabase.appendRemoteTaskEventOnce
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
const pendingRequest = harness.handler?.(
trustedEvent(harness.webContents),
managedSshRequest(
fixture.projectId,
requestId,
'managed-ssh-semantic-text'
)
)
await vi.waitFor(() => expect(commitStarted).toBe(true))
expect(resumed).not.toHaveBeenCalled()
expect(harness.webContents.send.mock.calls.some(([channel, value]) =>
channel === ipcChannels.agentEvent && value.requestId === requestId && value.type === 'text')).toBe(false)
releaseCommit()
await pendingRequest
await vi.waitFor(() =>
expect(
harness?.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(resumed.mock.calls).toEqual([['first'], ['duplicate']])
// One transaction per checkpoint plus one for the terminal event.
expect(
harness.assistantDatabase.appendRemoteTaskEventsOnce
).toHaveBeenCalledTimes(3)
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('batches a remote transcript page into one transaction and keeps order with local events', async () => {
const requestId = '00000000-0000-4000-8000-000000000767'
const conversationId = 'managed-ssh-remote-batch'
const remote = (semanticSequence: string, eventIndex: number) => ({
source: 'remote-semantic-transcript' as const,
bindingId: 'binding-remote-batch',
operationId: requestId,
semanticSequence,
eventIndex
})
const lifecycle: string[] = []
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
const id = request.requestId
yield { requestId: id, type: 'reasoning', delta: 'think', remoteProvenance: remote('1', 0) } as const
yield { requestId: id, type: 'text', delta: 'A', remoteProvenance: remote('1', 1) } as const
yield { requestId: id, type: 'text', delta: 'B', remoteProvenance: remote('1', 2) } as const
// Replayed event from an earlier page: already stored.
yield { requestId: id, type: 'text', delta: 'old', remoteProvenance: remote('1', 3) } as const
lifecycle.push('before-checkpoint')
yield { requestId: id, type: 'remote-semantic-checkpoint', remoteProvenance: remote('1', 4) } as const
lifecycle.push('after-checkpoint')
yield { requestId: id, type: 'text', delta: 'C', remoteProvenance: remote('2', 0) } as const
// A local event forces the pending remote batch to commit first.
yield { requestId: id, type: 'status', message: 'local status' } as const
lifecycle.push('after-local')
yield { requestId: id, type: 'remote-semantic-checkpoint', remoteProvenance: remote('2', 1) } as const
yield { requestId: id, type: 'done', remoteProvenance: remote('3', 0) } as const
yield { requestId: id, type: 'remote-semantic-checkpoint', remoteProvenance: remote('3', 1) } as const
}
}
const { harness, projectId } = createManagedSshHarness(selectedRuntime)
harness.assistantDatabase.getConversation.mockReturnValue({
id: conversationId, projectId, messages: []
})
harness.assistantDatabase.appendRemoteConversationTaskEventsBatch.mockImplementation(
({ events }) => {
lifecycle.push(`batch:${events.map(({ event }) => event.type).join(',')}`)
return events.map(({ event }) => !(event.type === 'text' && event.delta === 'old'))
}
)
await harness.handler?.(trustedEvent(harness.webContents), {
...managedSshRequest(projectId, requestId, conversationId),
currentUserMessageId: '00000000-0000-4000-8000-000000000768',
currentAssistantMessageId: '00000000-0000-4000-8000-000000000769'
})
await vi.waitFor(() => expect(lifecycle).toContain('batch:remote-semantic-checkpoint'))
expect(lifecycle).toEqual([
'before-checkpoint',
'batch:reasoning,text,text,text,remote-semantic-checkpoint',
'after-checkpoint',
'batch:text',
'after-local',
'batch:remote-semantic-checkpoint',
'batch:done',
'batch:remote-semantic-checkpoint'
])
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled()
expect(harness.assistantDatabase.appendRemoteTaskEventOnce).not.toHaveBeenCalled()
const publicEvents = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent)
.map(([, event]) => event)
.filter((event) => event.requestId === requestId)
// Renderer coalescing may merge adjacent text deltas; compare the stream.
const stream: string[] = []
for (const event of publicEvents) {
const previous = stream.at(-1)
if ((event.type === 'text' || event.type === 'reasoning') && previous?.startsWith(`${event.type}:`)) {
stream[stream.length - 1] = `${previous}${event.delta}`
} else {
stream.push(event.type === 'text' || event.type === 'reasoning'
? `${event.type}:${event.delta}`
: event.type === 'status' ? `status:${event.message}` : event.type)
}
}
expect(stream).toEqual(['reasoning:think', 'text:ABC', 'status:local status', 'done'])
expect(publicEvents.some((event) => event.type === 'text' && event.delta.includes('old'))).toBe(false)
await harness.dispose()
})
it('fails the run without forwarding or acknowledging when a remote batch commit fails', async () => {
const requestId = '00000000-0000-4000-8000-000000000770'
const remote = (eventIndex: number) => ({
source: 'remote-semantic-transcript' as const,
bindingId: 'binding-remote-batch-failure',
operationId: requestId,
semanticSequence: '1',
eventIndex
})
const resumedAfterCheckpoint = vi.fn()
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(request: { requestId: string }) {
yield { requestId: request.requestId, type: 'text', delta: 'lost', remoteProvenance: remote(0) } as const
yield { requestId: request.requestId, type: 'remote-semantic-checkpoint', remoteProvenance: remote(1) } as const
resumedAfterCheckpoint()
}
}
const fixture = createManagedSshHarness(selectedRuntime)
const { harness } = fixture
harness.assistantDatabase.appendRemoteTaskEventsOnce.mockImplementation(() => {
throw new Error('disk full')
})
await harness.handler?.(
trustedEvent(harness.webContents),
managedSshRequest(fixture.projectId, requestId, 'managed-ssh-batch-failure')
)
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
expect.stringMatching(/^(failed|interrupted)$/u),
expect.any(String)
)
)
expect(resumedAfterCheckpoint).not.toHaveBeenCalled()
const publicEvents = harness.webContents.send.mock.calls
.filter(([channel]) => channel === ipcChannels.agentEvent)
.map(([, event]) => event)
.filter((event) => event.requestId === requestId)
expect(publicEvents.some((event) => event.type === 'text')).toBe(false)
expect(publicEvents.at(-1)).toMatchObject({ type: 'error' })
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',
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', 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', 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',
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', 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, 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 = async () => await electronMocks.handlers.get(ipcChannels.conversationsList)!(event) as ConversationSnapshot[]
try {
harness.recoveryGetHandler?.(event)
await vi.waitFor(() => expect(ready.size).toBe(2))
await vi.waitFor(async () => expect((await list()).every(c => c.activeRequest?.questions.length === 1)).toBe(true))
const summaries = await electronMocks.handlers.get(ipcChannels.conversationsListSummaries)!(event, { detailIds: [] }) as ConversationSnapshot[]
expect(summaries).toEqual(await list())
for (const snapshot of summaries) {
expect(await 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)
for (const [index, task] of [...tasks].reverse().entries()) {
const snapshot = (await 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(async () => expect((await 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',
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,
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',
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,
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',
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.each(['list', 'claim', 'restore'] as const)('reserves a conversation during delayed queue %s and admits its dispatched request', async phase => {
const run = vi.fn(async function* (request: { requestId: string }) {
yield { requestId: request.requestId, type: 'done' } as const
})
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false, run })
const event = trustedEvent(harness.webContents)
const conversationId = crypto.randomUUID(), itemId = crypto.randomUUID()
const item = { id: itemId, conversationId, source: 'user' as const, label: 'queued', createdAt: new Date().toISOString() }
const input = { conversationId, prompt: 'queued', attachments: [], knowledgeLibraryIds: [] }
harness.assistantDatabase.listConversationQueueItems.mockReturnValueOnce([item])
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(item)
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({ source: 'user', item, payloadJson: JSON.stringify({ input, serializedContexts: '[]' }) })
let release!: () => void, entered = false
const gate = new Promise<void>(resolve => { release = resolve })
const wait = async () => { entered = true; await gate }
if (phase === 'restore') harness.contextManager.restoreFromQueue.mockImplementation(wait)
else beforeStorageCall.set(harness.assistantDatabase, async method => {
if (method === (phase === 'list' ? 'listConversationQueueItems' : 'claimConversationQueueItem')) await wait()
})
try {
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
await vi.waitFor(() => expect(entered).toBe(true))
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'overlap', knowledgeLibraryIds: [] })).rejects.toThrow('当前对话已有执行中的请求')
expect(run).not.toHaveBeenCalled()
release()
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.conversationQueueDispatch, expect.objectContaining({ item })))
await harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, queueItemId: itemId, prompt: 'queued', knowledgeLibraryIds: [] })
await vi.waitFor(() => expect(run).toHaveBeenCalledOnce())
} finally { release(); await harness.dispose() }
})
it.each(['lookup', 'claim', 'restore', 'send', 'release'] as const)('releases queue admission after a delayed %s failure', async phase => {
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false,
async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const }
})
const event = trustedEvent(harness.webContents), conversationId = crypto.randomUUID(), itemId = crypto.randomUUID()
const item = { id: itemId, conversationId, source: 'user' as const, label: 'queued', createdAt: new Date().toISOString() }
harness.assistantDatabase.listConversationQueueItems.mockReturnValueOnce([item])
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({ source: 'user', item,
payloadJson: JSON.stringify({ input: { conversationId, prompt: 'queued', attachments: [] }, serializedContexts: '[]' }) })
const failure = new Error(`${phase} failed`)
const reported = vi.spyOn(console, 'error').mockImplementation(() => {})
if (phase === 'restore' || phase === 'release') harness.contextManager.restoreFromQueue.mockImplementation(async () => { await nextTurn(); throw failure })
else if (phase === 'send') harness.webContents.send.mockImplementation(channel => { if (channel === ipcChannels.conversationQueueDispatch) throw failure })
else beforeStorageCall.set(harness.assistantDatabase, async method => {
if (method === (phase === 'lookup' ? 'listConversationQueueItems' : 'claimConversationQueueItem')) { await nextTurn(); throw failure }
})
if (phase === 'release') beforeStorageCall.set(harness.assistantDatabase, async method => {
if (method === 'releaseConversationUserQueueItem') { await nextTurn(); throw failure }
})
try {
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
await vi.waitFor(() => {
if (phase === 'restore' || phase === 'send') expect(harness.assistantDatabase.releaseConversationUserQueueItem).toHaveBeenCalledWith(itemId)
else expect(reported).toHaveBeenCalled()
})
beforeStorageCall.delete(harness.assistantDatabase)
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'retry', knowledgeLibraryIds: [] })).resolves.toBeUndefined()
} finally { await harness.dispose(); reported.mockRestore() }
})
it('releases a delayed queue claim instead of dispatching after shutdown starts', async () => {
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false })
const conversationId = crypto.randomUUID(), itemId = crypto.randomUUID()
const item = { id: itemId, conversationId, source: 'user' as const, label: 'queued', createdAt: new Date().toISOString() }
harness.assistantDatabase.listConversationQueueItems.mockReturnValueOnce([item])
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({ source: 'user', item, payloadJson: '{}' })
let release!: () => void, entered = false
const gate = new Promise<void>(resolve => { release = resolve })
beforeStorageCall.set(harness.assistantDatabase, async method => { if (method === 'claimConversationQueueItem') { entered = true; await gate } })
let disposal: Promise<void> | undefined
try {
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(trustedEvent(harness.webContents), conversationId)
await vi.waitFor(() => expect(entered).toBe(true))
disposal = harness.dispose()
await nextTurn()
release()
await disposal
expect(harness.assistantDatabase.releaseConversationUserQueueItem).toHaveBeenCalledWith(itemId)
expect(harness.webContents.send).not.toHaveBeenCalledWith(ipcChannels.conversationQueueDispatch, expect.anything())
} finally { release(); await (disposal ?? harness.dispose()) }
})
it('reserves a dispatched request ID during queue validation and releases failed validation', async () => {
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false,
async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const }
})
const event = trustedEvent(harness.webContents), conversationId = crypto.randomUUID(), itemId = crypto.randomUUID(), requestId = crypto.randomUUID()
const item = { id: itemId, conversationId, source: 'user' as const, label: 'queued', createdAt: new Date().toISOString() }
harness.assistantDatabase.listConversationQueueItems.mockReturnValueOnce([item])
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({ source: 'user', item, payloadJson: JSON.stringify({ conversationId, prompt: 'queued', attachments: [] }) })
let release!: () => void, entered = false
const gate = new Promise<void>(resolve => { release = resolve })
let finishRelease!: () => void, releasing = false
const releaseGate = new Promise<void>(resolve => { finishRelease = resolve })
try {
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.conversationQueueDispatch, expect.objectContaining({ item })))
beforeStorageCall.set(harness.assistantDatabase, async method => {
if (method === 'getConversationQueueItem') { entered = true; await gate; throw new Error('validation read failed') }
if (method === 'releaseConversationUserQueueItem') { releasing = true; await releaseGate }
})
const pending = expect(harness.handler!(event, { requestId, conversationId, queueItemId: itemId, prompt: 'queued', knowledgeLibraryIds: [] })).rejects.toThrow('validation read failed')
await vi.waitFor(() => expect(entered).toBe(true))
await expect(harness.handler!(event, { requestId, conversationId: crypto.randomUUID(), prompt: 'duplicate ID', knowledgeLibraryIds: [] })).rejects.toThrow('请求正在执行')
release()
await vi.waitFor(() => expect(releasing).toBe(true))
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'during cleanup', knowledgeLibraryIds: [] })).rejects.toThrow('当前对话已有执行中的请求')
finishRelease()
await pending
expect(harness.assistantDatabase.releaseConversationUserQueueItem).toHaveBeenCalledWith(itemId)
beforeStorageCall.delete(harness.assistantDatabase)
await expect(harness.handler!(event, { requestId, conversationId, prompt: 'retry', knowledgeLibraryIds: [] })).resolves.toBeUndefined()
} finally { release?.(); finishRelease(); await harness.dispose() }
})
it.each([false, true])('holds admission until renderer queue release settles (failure: %s)', async fail => {
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false,
async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const }
})
const event = trustedEvent(harness.webContents), conversationId = crypto.randomUUID(), itemId = crypto.randomUUID()
harness.assistantDatabase.getConversationQueueItem.mockReturnValue({ id: itemId, conversationId, source: 'user', label: 'queued', createdAt: new Date().toISOString() })
let release!: () => void, entered = false
const gate = new Promise<void>(resolve => { release = resolve })
beforeStorageCall.set(harness.assistantDatabase, async method => {
if (method === 'releaseConversationUserQueueItem') { entered = true; await gate; if (fail) throw new Error('release failed') }
})
const operation = electronMocks.handlers.get(ipcChannels.conversationQueueReleaseUser)!(event, itemId)
const pending = fail ? expect(operation).rejects.toThrow('release failed') : expect(operation).resolves.toBeUndefined()
try {
await vi.waitFor(() => expect(entered).toBe(true))
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'overlap', knowledgeLibraryIds: [] })).rejects.toThrow('当前对话已有执行中的请求')
release()
await pending
beforeStorageCall.delete(harness.assistantDatabase)
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'retry', knowledgeLibraryIds: [] })).resolves.toBeUndefined()
} finally { release(); await pending; await harness.dispose() }
})
it('reserves manual compaction before its first storage read and releases failed preparation', async () => {
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false,
async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const }
})
const event = trustedEvent(harness.webContents), conversationId = crypto.randomUUID(), requestId = crypto.randomUUID()
const compact = electronMocks.handlers.get(ipcChannels.agentCompactConversation)!
let release!: () => void, entered = false
const gate = new Promise<void>(resolve => { release = resolve })
beforeStorageCall.set(harness.assistantDatabase, async method => { if (method === 'getConversation') { entered = true; await gate; throw new Error('read failed') } })
const pending = expect(compact(event, { requestId, conversationId, runtimeSelection: { provider: 'model' }, history: [], historyMessageIds: [] })).rejects.toThrow('read failed')
try {
await vi.waitFor(() => expect(entered).toBe(true))
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'overlap', knowledgeLibraryIds: [] })).rejects.toThrow('当前对话已有执行中的请求')
release()
await pending
beforeStorageCall.delete(harness.assistantDatabase)
await expect(harness.handler!(event, { requestId, conversationId, prompt: 'retry', knowledgeLibraryIds: [] })).resolves.toBeUndefined()
} finally { release(); await pending; 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,
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: 'model' },
prompt: '等待 Runtime',
knowledgeLibraryIds: []
}
)
await vi.waitFor(() =>
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
)
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000729',
conversationId,
runtimeSelection: { provider: 'model' },
prompt: '不能并发',
knowledgeLibraryIds: []
})
).rejects.toThrow('当前对话已有执行中的请求')
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
await expect(Promise.resolve().then(() =>
harness.branchHandler?.(trustedEvent(harness.webContents), {
sourceConversationId: conversationId,
title: '等待 Runtime · 分支'
}))).rejects.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: 'model' 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)
})
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000727',
conversationId,
prompt: '当前回复',
knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
electronMocks.handlers.get(
ipcChannels.conversationQueueReady
)?.(trustedEvent(harness.webContents), conversationId)
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',
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: 'model' 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)
await vi.waitFor(() => 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,
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: 'model' 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,
knowledgeLibraryIds: []
})
).rejects.toThrow('会话记录不可用')
expect(
harness.assistantDatabase.releaseConversationUserQueueItem
).toHaveBeenCalledWith(itemId)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000748',
conversationId,
runtimeSelection: { provider: 'model' },
prompt: '失败后的新消息',
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: 'model' 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,
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({
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',
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,
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',
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',
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, undefined, false, undefined, {
database: { listKnowledgeBases: vi.fn(() => [{ id: libraryId, name: 'Knowledge' }]) }, retrieveMany
}, gateway)
const request = { prompt: 'use saved knowledge', 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.skipIf(!process.env.GOODBUDDY_NATIVE_CLIENT_INTEGRATION)('opens and stops official native clients from the real UI through preload, IPC and saved project settings', async () => {
const { createElement } = await import('react')
const { render, fireEvent, waitFor, cleanup } = await import('@testing-library/react/pure')
const rendererModule = '../renderer/src/RuntimeNativeClientActions'
const { RuntimeNativeClientActions } = await import(rendererModule) as {
RuntimeNativeClientActions: import('react').ComponentType<{
browser: boolean; contextKey: string; conversationId: string; prepareConversation: () => Promise<string>
onTerminal: (value: import('../shared/terminal-contracts').TerminalSnapshot) => void; notify: (value: unknown) => void
}>
}
const { default: i18n } = await import('i18next')
const { NativeClientCoordinator } = await import('./agent/native-client-coordinator')
const { RuntimeSettingsStore } = await import('./runtime-settings-store')
const { ApplicationSettingsStore } = await import('./application-settings-store')
const { ExecutionSpaceResolver } = await import('./execution-space/execution-space-resolver')
const { KnowledgeMcpGateway } = await import('./agent/knowledge-mcp-gateway')
const { TerminalSessionManager } = await import('./terminal/terminal-session-manager')
const { resolveNpmCliPaths } = await import('./local-tool-environment')
const { resolveBundledRuntimePaths } = await import('./agent/bundled-runtimes')
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-native-ui-'))
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
const liveModule = process.env.GOODBUDDY_NATIVE_CLIENT_LIVE_MODULE
const { createRequire } = await import('node:module')
const live = liveModule ? createRequire(import.meta.url)(liveModule) as {
begin: (fetcher: typeof fetch) => typeof fetch
profile: (runtime: string) => import('./runtime-settings-store').ResolvedModelProfile
web: (url: string, workspace: string) => Promise<void>
terminal: (runtime: string, write: (data: string) => Promise<unknown>, output: () => string) => Promise<void>
finish: () => Promise<void>
} : undefined
if (live) vi.stubGlobal('fetch', live.begin(fetch))
let coordinator: InstanceType<typeof NativeClientCoordinator> | undefined
let harness: ReturnType<typeof createHarness> | undefined
let terminalManager: InstanceType<typeof TerminalSessionManager> | undefined
try {
database.initialize(root)
const secrets = new Map<string, string>()
const cipher = { isAvailable: () => true,
encrypt: (value: string) => { const id = crypto.randomUUID(); secrets.set(id, value); return Buffer.from(id) },
decrypt: (value: Buffer) => secrets.get(value.toString())! }
const settingsStore = new RuntimeSettingsStore(join(root, 'settings.json'), cipher, {})
const profileId = '00000000-0000-4000-8000-000000000221'
const inputDefaults = { ...defaultRuntimeSettings } as Record<string, unknown>
delete inputDefaults.supportsImageInput
const selectedProfile = live?.profile('deepseek-harness')
await settingsStore.update(runtimeSettingsInputSchema.parse({ ...inputDefaults, apiKey: { action: 'keep' },
provider: 'deepseek-harness', workspacePath: root,
modelProfiles: [{ name: 'Native UI Test', baseUrl: 'http://127.0.0.1:1', modelName: 'native-ui-model',
protocol: 'openai-chat-completions', authentication: 'api-key', imageGenerationQuality: 'auto',
...selectedProfile, id: profileId, apiKey: { action: 'replace', value: selectedProfile?.apiKey ?? 'native-ui-secret' } }],
defaultModelProfileId: profileId, deepseekHarnessModelSource: { kind: 'profile', profileId }
}))
const project = database.createProject({ name: 'Native UI', description: '', rootPath: root,
runtimeSelection: { provider: 'deepseek-harness', model: { kind: 'profile', profileId } } })
const conversationId = '00000000-0000-4000-8000-000000000222'
const now = Date.now()
const save = async (): Promise<string> => {
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Native UI', updatedAt: now }, messages: [] }])
return conversationId
}
await save()
const capabilities = new CapabilityService(join(root, 'capabilities.json'), join(root, 'builtin'), join(root, 'imported'), cipher)
const opened: string[] = []
const terminalEvents: import('../shared/terminal-contracts').TerminalEvent[] = []
terminalManager = new TerminalSessionManager({ database, executionSpaceResolver: new ExecutionSpaceResolver(),
targetResolver: {} as never, sshPool: {} as never, remoteEnabled: () => false,
deliverEvent: (ownerId, event) => {
expect(ownerId).toBe(9)
terminalEvents.push(event)
terminalManager!.acknowledge(ownerId, event.sessionId, event.sequence)
}
})
const applicationSettingsStore = new ApplicationSettingsStore(join(root, 'application.json'))
if (process.env.NATIVE_DSH_TEST_NODE) await applicationSettingsStore.update({
localToolEnvironment: { ...(await applicationSettingsStore.get()).localToolEnvironment,
node: { source: 'custom', executablePath: process.env.NATIVE_DSH_TEST_NODE } }
})
coordinator = new NativeClientCoordinator({ database, settingsStore,
openModelCallLedger: async path => new AgentModelCallLedger(path),
applicationSettingsStore,
capabilities, executionSpaceResolver: new ExecutionSpaceResolver(), terminalManager,
localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady: async () => undefined } as never,
rootDirectory: join(root, 'clients'), managedNodeDirectory: join(root, 'node'),
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
npmCliPath: resolveNpmCliPaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }).npmCliPath,
createGateway: () => new KnowledgeMcpGateway({} as never),
openExternal: async (url) => { opened.push(url) }
})
harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, undefined, false, undefined, coordinator, terminalManager)
const event = trustedEvent(harness.webContents)
electronMocks.invoke.mockImplementation((channel: string, input: unknown) => electronMocks.handlers.get(channel)!(event, input))
await import('../preload/index')
const notify = vi.fn()
const view = render(createElement(RuntimeNativeClientActions, { browser: true, contextKey: project.id,
conversationId, prepareConversation: save, onTerminal: vi.fn(), notify }))
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.browser') }))
await waitFor(() => {
expect(notify).not.toHaveBeenCalled()
expect(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.reopen') })).toBeEnabled()
}, { timeout: 120_000 })
expect(opened).toHaveLength(1)
const handle = await coordinator.get(9, conversationId)
expect(handle?.serviceId).toBeTruthy()
expect(await coordinator.get(10, conversationId)).toBeNull()
await coordinator.stop(10, handle!.serviceId)
expect(await coordinator.get(9, conversationId)).toEqual(handle)
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Native UI', updatedAt: now + 1 }, messages: [] }])
expect(await coordinator.get(9, conversationId)).toBeNull()
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Native UI', updatedAt: now + 2 }, messages: [] }])
expect(await coordinator.get(9, conversationId)).toEqual(handle)
const config = await readFile(join(root, 'clients', 'dsh', handle!.serviceId, 'cordis.patch.yml'), 'utf8')
expect(config).toContain(selectedProfile?.modelName ?? 'native-ui-model')
expect(config).not.toContain('native-ui-secret')
if (selectedProfile?.apiKey) expect(config).not.toContain(selectedProfile.apiKey)
const auth = await fetch(opened[0]!, { redirect: 'manual' })
expect(auth.status).toBe(303)
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
const page = await fetch(new URL('/', opened[0]!), { headers: { cookie } })
expect(page.status).toBe(200)
expect(await page.text()).toContain('<html')
await live?.web(opened[0]!, root)
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.reopen') }))
await waitFor(() => expect(opened).toHaveLength(2))
expect(await coordinator.get(9, conversationId)).toEqual(handle)
await waitFor(() => expect(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.stop') })).toBeEnabled())
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.stop') }))
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.confirmStop') }))
await waitFor(() => expect(view.queryByRole('button', { name: i18n.t('app:composer.nativeClient.stop') })).not.toBeInTheDocument(), { timeout: 10_000 })
expect(await coordinator.get(9, conversationId)).toBeNull()
await expect(fetch(new URL('/', opened[0]!))).rejects.toThrow()
const terminals: import('../shared/terminal-contracts').TerminalSnapshot[] = []
for (const provider of ['continue', 'opencode'] as const) {
if (live) {
const profile = live.profile(provider)
await settingsStore.update(runtimeSettingsInputSchema.parse({ ...inputDefaults, apiKey: { action: 'keep' },
provider, workspacePath: root, defaultModelProfileId: profileId,
modelProfiles: [{ ...profile, id: profileId, imageGenerationQuality: 'auto', apiKey: { action: 'replace', value: profile.apiKey } }],
[`${provider}ModelSource`]: { kind: 'profile', profileId }
}))
}
database.updateProject(project.id, { ...project, runtimeSelection: { provider, model: { kind: 'profile', profileId } } })
const onTerminal = vi.fn<(value: import('../shared/terminal-contracts').TerminalSnapshot) => void>()
notify.mockClear()
view.rerender(createElement(RuntimeNativeClientActions, { browser: false, contextKey: `${project.id}:${provider}`,
conversationId, prepareConversation: save, onTerminal, notify }))
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.terminal') }))
await waitFor(() => {
expect(notify).not.toHaveBeenCalled()
expect(onTerminal).toHaveBeenCalledWith(expect.objectContaining({ state: 'running', workingDirectory: root }))
}, { timeout: 30_000 })
const terminal = onTerminal.mock.calls[0]![0]
terminals.push(terminal)
await waitFor(() => expect(terminalEvents.some(event => event.sessionId === terminal.sessionId && event.type === 'output' && /Continue|OpenCode|opencode|Native UI Test|native-ui-model/u.test(event.data))).toBe(true), { timeout: 20_000 })
await live?.terminal(provider,
data => (globalThis as unknown as { goodbuddy: import('../shared/contracts').DesktopApi }).goodbuddy.terminal.write({ sessionId: terminal.sessionId, data }),
() => terminalEvents.flatMap(event => event.sessionId === terminal.sessionId && event.type === 'output' ? [event.data] : []).join(''))
}
await harness.dispose()
harness = undefined
for (const terminal of terminals) expect(() => terminalManager!.snapshot(9, terminal.sessionId)).toThrow()
} finally {
cleanup()
vi.unstubAllGlobals()
await harness?.dispose()
await coordinator?.dispose()
await terminalManager?.dispose()
database.close()
await rm(root, { recursive: true, force: true })
await live?.finish()
}
}, 360_000)
it('tests an unsaved Obsidian UI draft through registered IPC and bundled MCPVault, then saves and refreshes', async () => {
const { createElement } = await import('react')
const { render, fireEvent, waitFor, cleanup } = await import('@testing-library/react/pure')
// Load the renderer at test runtime without pulling its JSX project into the Node typecheck.
const rendererModule = '../renderer/src/McpSettingsSection'
const { McpSettingsSection } = await import(rendererModule) as {
McpSettingsSection: import('react').ComponentType<{ onOpenImageModelSettings: () => void }>
}
const { default: i18n } = await import('i18next')
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-obsidian-'))
let harness: ReturnType<typeof createHarness> | undefined
try {
const vaultPath = join(root, 'Draft Vault')
await mkdir(vaultPath)
await writeFile(join(vaultPath, 'existing.md'), '# Keep this note\n')
const appPath = join(root, 'app.asar')
await packageObsidianMcpVault(process.cwd(), join(root, 'app.asar.unpacked', 'out', 'main', 'obsidian-mcpvault'))
const service = new ObsidianService({
appPath,
launchEnvironmentProvider: () => ({ PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}` })
})
const capabilities = new CapabilityService(
join(root, 'capabilities.json'), join(root, 'builtin'), join(root, 'imported'),
{ isAvailable: () => true, encrypt: (value) => Buffer.from(value), decrypt: (value) => value.toString() }
)
harness = createHarness(
{ capability: 'chat' }, undefined, undefined, false,
undefined, undefined, undefined, false, undefined,
{
getSnapshot: capabilities.getSnapshot.bind(capabilities),
updateObsidianSettings: capabilities.updateObsidianSettings.bind(capabilities)
},
undefined, undefined, undefined, undefined, service
)
const event = trustedEvent(harness.webContents)
const test = electronMocks.handlers.get(ipcChannels.capabilitiesTestObsidianConnection)!
const save = electronMocks.handlers.get(ipcChannels.capabilitiesUpdateObsidianSettings)!
const pick = electronMocks.handlers.get(ipcChannels.capabilitiesSelectObsidianVault)!
expect(test).toBeTypeOf('function')
expect(save).toBeTypeOf('function')
expect(pick).toBeTypeOf('function')
// Replace only Electron transport; UI, handlers, persistence and MCPVault are real.
const invoke = async (channel: string, input?: unknown) =>
electronMocks.handlers.get(channel)!(event, input)
vi.stubGlobal('goodbuddy', { capabilities: {
getSnapshot: () => invoke(ipcChannels.capabilitiesSnapshot),
testObsidianConnection: (input: unknown) => invoke(ipcChannels.capabilitiesTestObsidianConnection, input),
updateObsidianSettings: (input: unknown) => invoke(ipcChannels.capabilitiesUpdateObsidianSettings, input),
selectObsidianVault: () => invoke(ipcChannels.capabilitiesSelectObsidianVault)
} })
const view = render(createElement(McpSettingsSection, { onOpenImageModelSettings: vi.fn() }))
const testButton = view.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') })
await waitFor(() => expect(testButton).toBeEnabled())
fireEvent.change(view.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') }), { target: { value: 'folder' } })
const pathInput = view.getByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })
fireEvent.change(pathInput, { target: { value: vaultPath } })
fireEvent.click(testButton)
await waitFor(() => {
expect(view.getByText(vaultPath)).toBeVisible()
expect(testButton).toBeEnabled()
}, { timeout: 10_000 })
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath: '' })
expect(harness.onRuntimeSettingsChanged).not.toHaveBeenCalled()
expect(await readdir(vaultPath)).toEqual(['existing.md'])
expect(await readFile(join(vaultPath, 'existing.md'), 'utf8')).toBe('# Keep this note\n')
electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: true, filePaths: [join(root, 'Cancelled Vault')] })
const pickButton = view.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.select') })
fireEvent.click(pickButton)
await waitFor(() => {
expect(electronMocks.showOpenDialog).toHaveBeenCalledOnce()
expect(pickButton).toBeEnabled()
expect(pathInput).toHaveValue(vaultPath)
})
electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: [vaultPath] })
await expect(pick(event)).resolves.toBe(vaultPath)
expect(electronMocks.showOpenDialog).toHaveBeenLastCalledWith(
expect.objectContaining({ webContents: harness.webContents }), { properties: ['openDirectory'] }
)
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath: '' })
fireEvent.click(view.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') }))
await waitFor(() => {
expect(view.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: vaultPath }))).toBeVisible()
expect(testButton).toBeEnabled()
})
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
harness.onRuntimeSettingsChanged.mockClear()
await expect(save(event, { vaultPath: ` ${vaultPath} ` })).resolves.toMatchObject({
obsidian: { vaultPath },
builtinMcpServers: expect.arrayContaining([expect.objectContaining({ id: 'obsidian', enabled: false })])
})
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath })
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
await expect(test(event, { vaultPath: join(root, 'missing') })).rejects.toThrow('missing or inaccessible')
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath })
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
await expect(Promise.resolve().then(() => save(event, { vaultPath: 42 }))).rejects.toThrow()
await expect(test(event, { vaultPath: 42 })).rejects.toThrow()
const untrustedEvent = { ...event, sender: { ...harness.webContents, id: 99 } }
await expect(Promise.resolve().then(() => save(untrustedEvent, { vaultPath }))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(test(untrustedEvent, { vaultPath })).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(pick(untrustedEvent)).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
} finally {
cleanup()
vi.unstubAllGlobals()
await harness?.dispose()
await rm(root, { recursive: true, force: true })
}
}, 60_000)
it.each([
{ enabled: false, assigned: true, access: undefined },
{ enabled: true, assigned: false, access: undefined },
{ enabled: true, assigned: true, access: 'write' }
])('routes Obsidian request grants with saved settings: %j', async ({ enabled, assigned, access }) => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-obsidian-grant-'))
let harness: ReturnType<typeof createHarness> | undefined
try {
const capabilities = new CapabilityService(
join(root, 'capabilities.json'), join(root, 'builtin'), join(root, 'imported'),
{ isAvailable: () => true, encrypt: (value) => Buffer.from(value), decrypt: (value) => value.toString() }
)
const settings = { vaultPath: join(root, 'Saved Vault') }
// This harness exercises Obsidian grants and does not install a browser service.
await capabilities.setBuiltinMcpServerEnabled('builtin-browser', false)
await capabilities.updateObsidianSettings(settings)
if (enabled) await capabilities.setBuiltinMcpServerEnabled('obsidian', true)
if (!assigned) await capabilities.setBuiltinMcpServerAssignments('obsidian', ['continue'])
const received: AgentExecutionRequest[] = []
const gateway = {
grant: vi.fn(() => 'obsidian-capability'),
getAvailableToolNames: vi.fn(() => ['obsidian_list_vaults']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const getObsidianSettings = vi.fn(() => capabilities.getObsidianSettings())
harness = createHarness({
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
async *run(request: AgentExecutionRequest) {
received.push(request)
yield { requestId: request.requestId, type: 'done' }
}
}, undefined, undefined, false, undefined, undefined, gateway, false, undefined, {
getEnabledBuiltinMcpServerIds: capabilities.getEnabledBuiltinMcpServerIds.bind(capabilities),
getObsidianSettings
})
const requestId = '00000000-0000-4000-8000-000000000026'
await harness.handler!(trustedEvent(harness.webContents), {
requestId, conversationId: 'obsidian-grant', prompt: 'List my vaults', knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(harness!.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
expect(received).toHaveLength(1)
if (access) {
expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(
requestId, [], expect.any(AbortSignal), 'none', undefined, undefined, undefined, undefined,
{ settings, access }
)
expect(received[0]?.knowledgeCapabilityToken).toBe('obsidian-capability')
expect(getObsidianSettings).toHaveBeenCalledOnce()
expect(gateway.revoke).toHaveBeenCalledWith('obsidian-capability')
} else {
expect(gateway.grant).not.toHaveBeenCalled()
expect(getObsidianSettings).not.toHaveBeenCalled()
expect(received[0]?.knowledgeCapabilityToken).toBeUndefined()
}
} finally {
await harness?.dispose()
await rm(root, { recursive: true, force: true })
}
})
it.each(['model', 'opencode', 'continue', 'deepseek-harness'])('binds Story Graph to the current project for %s and omits it when Supervisor is off', async runtimeId => {
const received: AgentExecutionRequest[] = []
const binding = { available: vi.fn(async () => true), call: vi.fn() }
const gateway = { grant: vi.fn(() => 'graph-capability'), getAvailableToolNames: vi.fn(() => ['story_graph_search', 'story_graph_get_context', 'story_graph_read_source']),
bindRemoteStoryGraph: vi.fn(() => binding), drainReferences: vi.fn(() => []), revoke: vi.fn() }
const harness = createHarness({ runtimeId, capability: 'chat', supportsToolExecution: true,
async *run(request: AgentExecutionRequest) { received.push(request); yield { requestId: request.requestId, type: 'done' } }
}, undefined, undefined, false, undefined, undefined, gateway, false, undefined, {
getEnabledBuiltinMcpServerIds: vi.fn(async () => ['story-graph'])
})
try {
const projectId = '00000000-0000-4000-8000-000000000088'
for (const [enabled, conversationEnabled] of [[true, true], [false, true], [true, false]]) {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: enabled })
harness.assistantDatabase.isConversationStoryGraphEnabled.mockReturnValue(conversationEnabled!)
const requestId = crypto.randomUUID()
await harness.handler!(trustedEvent(harness.webContents), { requestId, projectId, conversationId: 'graph-grant', prompt: 'Continue work', knowledgeLibraryIds: [] })
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
}
expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(expect.any(String), [], expect.any(AbortSignal), 'none',
undefined, undefined, undefined, undefined, undefined, { projectId, conversationId: 'graph-grant', runtimeTarget: runtimeId })
expect(received[0]).toMatchObject({ knowledgeCapabilityToken: 'graph-capability', storyGraphBinding: binding })
expect(received[1]).not.toHaveProperty('storyGraphBinding')
expect(received[2]).not.toHaveProperty('storyGraphBinding')
expect(gateway.bindRemoteStoryGraph).toHaveBeenCalledOnce()
} finally { await harness.dispose() }
})
it('grants enabled Magic Notes write tools for both reading and writing requests', 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(() => [
'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,
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true
)
const event = trustedEvent(harness.webContents)
const readRequestId = '00000000-0000-4000-8000-000000000023'
const writeRequestId = '00000000-0000-4000-8000-000000000024'
await harness.handler?.(event, {
requestId: readRequestId,
conversationId: 'notes-read',
prompt: '读取笔记',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
readRequestId,
'completed'
)
)
await harness.handler?.(event, {
requestId: writeRequestId,
conversationId: 'notes-write',
prompt: '创建笔记',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
writeRequestId,
'completed'
)
)
expect(knowledgeGateway.grant).toHaveBeenNthCalledWith(
1,
readRequestId,
[],
expect.any(AbortSignal),
'write'
)
expect(knowledgeGateway.grant).toHaveBeenNthCalledWith(
2,
writeRequestId,
[],
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,
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: '读取笔记',
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 for ordinary requests', 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,
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
undefined,
{ getEnabledBuiltinMcpServerIds },
browserControl
)
const event = trustedEvent(harness.webContents)
const readRequestId = '00000000-0000-4000-8000-000000000026'
const writeRequestId = '00000000-0000-4000-8000-000000000027'
await harness.handler?.(event, {
requestId: readRequestId,
conversationId: 'browser-ask',
prompt: '只回答',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
readRequestId,
'completed'
)
)
await harness.handler?.(event, {
requestId: writeRequestId,
conversationId: 'browser-execute',
prompt: '打开网页',
knowledgeLibraryIds: []
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
writeRequestId,
'completed'
)
)
expect(knowledgeGateway.grant).toHaveBeenCalledTimes(2)
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
readRequestId, [], expect.any(AbortSignal), 'none', undefined,
'browser-ask', primaryTabId,
expect.objectContaining({ conversationId: 'browser-ask', tabId: primaryTabId, owner: readRequestId }),
undefined, undefined
)
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
writeRequestId,
[],
expect.any(AbortSignal),
'none',
undefined,
'browser-execute',
primaryTabId,
expect.objectContaining({
conversationId: 'browser-execute',
tabId: primaryTabId,
owner: writeRequestId
}),
undefined,
undefined
)
expect(receivedRequests[0]).toMatchObject({ knowledgeCapabilityToken: 'browser-capability', browserTabId: primaryTabId })
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,
writeRequestId,
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,
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: '使用当前网页',
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,
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',
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,
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: '使用浏览器',
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(
'does not grant or advertise scoped data tools to external OpenCode',
async () => {
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,
undefined,
false,
undefined,
undefined,
knowledgeGateway,
true
)
const requestId = '00000000-0000-4000-8000-000000000025'
await harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'external-opencode',
prompt: '读取笔记',
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,
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',
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,
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',
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',
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,
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: '如何离线部署?',
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,
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,
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 TestKnowledgeService({
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, 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,
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' })
)
// Resolved selections name the model source explicitly.
expect(selectedRuntimes.getStatus).toHaveBeenLastCalledWith({
provider: 'opencode',
runtimeConfig: true
})
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',
runtimeSelection: firstSelection
}),
harness.handler?.(event, {
requestId: '00000000-0000-4000-8000-000000000012',
conversationId: 'conversation-two',
prompt: 'second request',
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, undefined, false, selectedRuntimes)
const projectId = '00000000-0000-4000-8000-000000000101'
const fixedProfileId = '00000000-0000-4000-8000-000000000042'
const project = { id: projectId, rootPath: 'C:\\ProjectWorkspace' }
harness.getResolvedSettings.mockResolvedValue({
provider: 'model', defaultModelProfileId: fixedProfileId, opencodeBaseUrl: '',
modelProfiles: [{
id: fixedProfileId, name: 'Fixed', modelName: 'fixed', baseUrl: 'https://models.example/v1',
protocol: 'openai-chat-completions', authentication: 'none'
}]
})
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'
})
// No project model: OpenCode uses its global default (own configuration here).
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith(
{ provider: 'opencode', runtimeConfig: true }, expect.anything()
)
harness.assistantDatabase.getProject.mockReturnValue({
...project,
runtimeSelection: { provider: 'continue', model: { kind: 'profile', profileId: fixedProfileId } }
})
await harness.handler?.(event, {
projectId, conversationId: 'project-rule-two',
requestId: '00000000-0000-4000-8000-000000000012', prompt: 'two'
})
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith(
{ provider: 'continue', profileId: fixedProfileId }, expect.anything()
)
await harness.handler?.(event, {
projectId, conversationId: 'project-rule-three', runtimeSelection: { provider: 'model' },
requestId: '00000000-0000-4000-8000-000000000013', prompt: 'three'
})
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,
undefined,
false,
selectedRuntimes,
undefined,
undefined,
false,
undefined,
{
getWebSearchCapabilityStatus,
getEnabledBuiltinMcpServerIds
}
)
const event = trustedEvent(harness.webContents)
harness.getApplicationSettings.mockClear()
const request = {
requestId: '00000000-0000-4000-8000-000000000013',
conversationId: 'duplicate-request',
projectId: '00000000-0000-4000-8000-000000000101',
prompt: 'run once'
}
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,
undefined,
false,
undefined,
undefined,
undefined,
false,
goodbuddyConfigService,
{
getWebSearchCapabilityStatus,
getEnabledBuiltinMcpServerIds
}
)
harness.getApplicationSettings.mockClear()
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',
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'
})
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'
})
).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'
})
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'
})
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'
})
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)(
'dispatches ordinary requests without an authorizer at the %s Runtime boundary',
async (runtimeId) => {
let received:
| {
request: { requestId: string }
authorize: unknown
}
| undefined
const runtime = {
runtimeId,
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
request: { requestId: 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'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(received?.request).not.toHaveProperty('workMode')
expect(received?.authorize).toBeUndefined()
expect(
harness.assistantDatabase.createTask
).toHaveBeenCalledWith(
expect.objectContaining({ id: requestId })
)
await harness.dispose()
}
)
it('runs DeepSeek Harness tools without a general authorizer', async () => {
const receivedAuthorizers: unknown[] = []
const runtime = {
runtimeId: 'deepseek-harness',
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>
) {
receivedAuthorizers.push(authorize)
yield { requestId: request.requestId, type: 'tool', callId: 'write-file',
name: 'write_file', state: 'completed', summary: 'Wrote file' }
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
for (const index of [0, 1]) {
const requestId = `3f496642-f47d-4e0a-8944-a32c77b0d6e${index}`
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: `conversation-${index}`,
prompt: 'run the task'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
}
expect(receivedAuthorizers).toEqual([undefined, undefined])
await harness.dispose()
})
it.each(['model', 'opencode'] as const)(
'uses capability instructions without mode metadata for %s',
async (runtimeId) => {
let receivedRequest:
| { requestId: string; prompt: string }
| undefined
const runtime = {
runtimeId,
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: {
requestId: string
prompt: 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'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(receivedRequest).not.toHaveProperty('workMode')
expect(receivedRequest?.prompt).toContain('enabled capabilities')
expect(receivedRequest?.prompt).not.toContain('Work mode:')
expect(
harness.assistantDatabase.createTask
).toHaveBeenCalledWith(
expect.objectContaining({ id: requestId })
)
await harness.dispose()
}
)
it('sends capability instructions only once to runtimes that consume trusted instructions', async () => {
let receivedRequest:
| { requestId: string; prompt: string; trustedInstructions?: string }
| undefined
const runtime = {
runtimeId: 'opencode',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
consumesTrustedInstructions: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: {
requestId: string
prompt: string
trustedInstructions?: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '5c3e1f7a-2b8d-4e6f-9a1c-7d2b4e6f8a10'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'fix the bug'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(receivedRequest?.prompt).toBe('fix the bug')
expect(receivedRequest?.trustedInstructions).toContain('enabled capabilities')
await harness.dispose()
})
it.each([
{ route: 'manual', consumesTrustedInstructions: true },
{ route: 'smart', consumesTrustedInstructions: true },
{ route: 'manual', consumesTrustedInstructions: false }
] as const)('applies a $route expert as a role prompt on the conversation runtime (system channel: $consumesTrustedInstructions)', async ({ route, consumesTrustedInstructions }) => {
const received: Array<{ requestId: string; prompt: string; trustedInstructions?: string }> = []
const runtime = {
runtimeId: 'opencode',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
consumesTrustedInstructions,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: { requestId: string; prompt: string; trustedInstructions?: string }) {
received.push(request)
yield { requestId: request.requestId, type: 'text', delta: '角色回答' }
yield { requestId: request.requestId, type: 'done' }
}
}
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(),
cancelAll: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const harness = createHarness(runtime, undefined, subagentService, true)
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([expert])
vi.mocked(harness.assistantDatabase.getExpert).mockReturnValue(expert)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-role',
prompt: '请做资料分析',
...(route === 'manual' ? { expertId: expert.id } : { smartRouting: true })
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed')
)
expect(received).toHaveLength(1)
const request = received[0]!
expect(request.trustedInstructions).toContain('enabled capabilities')
expect(request.trustedInstructions).toContain('"研究专家"')
expect(request.trustedInstructions).toContain('Analyze evidence.')
if (consumesTrustedInstructions) {
expect(request.prompt).toBe('请做资料分析')
} else {
expect(request.prompt).toMatch(/enabled capabilities[\s\S]*Analyze evidence\.\n\n请做资料分析$/u)
}
expect(subagentService.run).not.toHaveBeenCalled()
expect(harness.assistantDatabase.appendTaskEvent).not.toHaveBeenCalledWith(
requestId,
'subagent',
expect.anything()
)
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({ type: 'text', delta: '角色回答' })
)
await harness.dispose()
})
it('binds expert team writes to each request project instead of the global workspace', async () => {
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
const directory = await mkdtemp(join(tmpdir(), 'expert-projects-'))
const globalRoot = join(directory, 'global')
const roots = [join(directory, 'alpha'), join(directory, 'beta')]
await Promise.all([globalRoot, ...roots].map(root => mkdir(root)))
const settings = { ...runtimeUpdateFixture(globalRoot).publicSettings,
provider: 'model' as const, modelAuthentication: 'none' as const, apiKey: 'test-only',
runtimeCustomization: defaultRuntimeSettings.runtimeCustomization }
const projectIds: string[] = [crypto.randomUUID(), crypto.randomUUID()]
let toolCall = 0
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
const body = JSON.parse(String(init?.body))
const messages = body.messages as Array<{ role: string; content: string }>
if (!body.tools) return new Response('data: {"choices":[{"delta":{"content":"Combined answer"},"finish_reason":null}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
{ headers: { 'content-type': 'text/event-stream' } })
if (body.tools && !messages.some(message => message.role === 'tool')) {
const label = messages.some(message => message.role === 'user' && message.content.includes('alpha')) ? 'alpha' : 'beta'
const id = `write-${++toolCall}`
expect(body.tools).toContainEqual(expect.objectContaining({ function: expect.objectContaining({ name: 'workspace_apply_patch' }) }))
return Response.json({ choices: [{ message: { role: 'assistant', content: null,
tool_calls: [{ id, type: 'function', function: { name: 'workspace_apply_patch',
arguments: JSON.stringify({ patch: `*** Begin Patch\n*** Add File: ${id}.txt\n+${label}\n*** End Patch` }) } }] }, finish_reason: 'tool_calls' }] })
}
return Response.json({ choices: [{ message: { role: 'assistant', content: 'Written' }, finish_reason: 'stop' }] })
})
vi.stubGlobal('fetch', fetcher)
runtimeFactoryMocks.createDefaultModelRuntime.mockImplementation(actual.createDefaultModelRuntime)
const expert = { id: crypto.randomUUID(), name: 'Writer', description: '', systemInstructions: 'Write the requested file.',
routingKeywords: ['write'], enabled: true, createdAt: '', updatedAt: '' }
const requests: Parameters<SubagentService['run']>[0][] = []
const db = { createTask: vi.fn(), updateTaskStatus: vi.fn(), appendTaskEvent: vi.fn() }
const service = new SubagentService(async input => {
requests.push(input)
return createSubagentRuntime(input, settings,
async (selectedSettings, executionSpace) => actual.createAgentRuntime(globalRoot, selectedSettings, { executionSpace }),
async () => { throw new Error('Local experts must not select a remote runtime') })
}, db as never)
const ordinary = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run: vi.fn() }
const getRuntime = vi.fn<SelectedRuntimeResolver['getRuntime']>(async () => ordinary as never)
const harness = createHarness(ordinary, undefined, service as unknown as Record<string, unknown>, true, { getRuntime })
harness.getResolvedSettings.mockResolvedValue(settings)
harness.assistantDatabase.getProject.mockImplementation(id => ({ id, rootPath: roots[projectIds.indexOf(id)], runtimeSelection: { provider: 'model' } }))
harness.assistantDatabase.getExpert.mockReturnValue(expert)
harness.assistantDatabase.listExperts.mockReturnValue([expert, { ...expert, id: crypto.randomUUID(), name: 'Second', routingKeywords: [] }])
try {
for (const [index, projectId] of projectIds.entries()) {
const requestId = crypto.randomUUID()
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: `project-${index}`, projectId,
prompt: `write ${index === 0 ? 'alpha' : 'beta'}`, teamMode: true })
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
const files = await readdir(roots[index]!)
expect(files).toHaveLength(2)
for (const file of files) expect(await readFile(join(roots[index]!, file), 'utf8')).toBe(index === 0 ? 'alpha' : 'beta')
for (const input of requests.filter(input => input.parentRequest.requestId === requestId)) {
expect(input.executionSpace).toBe(getRuntime.mock.calls[index]?.[1])
expect(input.executionSpace).toMatchObject({ kind: 'local', rootPath: roots[index] })
}
}
expect(await readdir(globalRoot)).toEqual([])
expect(ordinary.run).not.toHaveBeenCalled()
} finally {
await harness.dispose()
vi.unstubAllGlobals()
runtimeFactoryMocks.createDefaultModelRuntime.mockReset()
await rm(directory, { recursive: true, force: true })
}
})
it.each(['opencode', 'continue'] as const)('uses managed %s for SSH expert team members with their profile and parent bindings', async (provider) => {
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
const globalRoot = await mkdtemp(join(tmpdir(), 'expert-ssh-'))
const projectId = crypto.randomUUID()
const remoteAccess = {
getIdentity: vi.fn(async () => ({ kind: 'remote', id: 'ssh:test', canonicalDisplayPath: '/remote/project', access: 'read-only' })),
dispose: vi.fn(async () => {})
} as unknown as WorkspaceAccess
const executionSpace: ExecutionSpaceDescriptor = { kind: 'ssh', hostId: crypto.randomUUID(), remoteRootPath: '/remote/project',
cacheIdentity: 'ssh:test', routeIdentity: 'ssh:project', workspaceAccess: remoteAccess }
const resolveSpace = vi.spyOn(ExecutionSpaceResolver.prototype, 'resolveProject').mockReturnValue(executionSpace)
const settings = { ...runtimeUpdateFixture(globalRoot).publicSettings, provider: 'model' as const,
apiKey: 'test-only', runtimeCustomization: defaultRuntimeSettings.runtimeCustomization }
const expertProfileId = crypto.randomUUID()
settings.modelProfiles.push({ ...settings.modelProfiles[0]!, id: expertProfileId, name: 'Expert connection' })
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
const body = JSON.parse(String(init?.body))
expect(body.tools).toBeUndefined()
return new Response('data: {"choices":[{"delta":{"content":"Remote summary"},"finish_reason":null}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
{ headers: { 'content-type': 'text/event-stream' } })
})
vi.stubGlobal('fetch', fetcher)
runtimeFactoryMocks.createDefaultModelRuntime.mockImplementation(actual.createDefaultModelRuntime)
const binding = { context: {} as never, describe: vi.fn(), call: vi.fn(), save: vi.fn(), readForSave: vi.fn() }
const browserTabId = browserTabIdSchema.parse(crypto.randomUUID())
const createLocal = vi.fn(async () => { throw new Error('SSH experts must not create Desktop model tools') })
const createRemote = vi.fn<SelectedRuntimeResolver['getRuntime']>(async () => ({
async *run(request: AgentExecutionRequest) {
expect(request.imageToolBinding).toBe(binding)
expect(request.knowledgeCapabilityToken).toBe('parent-scoped-token')
expect(request.browserTabId).toBe(browserTabId)
expect(request.prompt).toContain('Work in the project.')
yield { requestId: request.requestId, type: 'text', delta: 'Remote expert result' } as const
yield { requestId: request.requestId, type: 'done' } as const
},
releaseConversation: vi.fn(), dispose: vi.fn()
}) as never)
const factory = vi.fn(async (input: Parameters<SubagentService['run']>[0]) => {
expect(input.executionSpace).toBe(executionSpace)
return createSubagentRuntime(input, settings, createLocal, createRemote)
})
const service = new SubagentService(factory, { createTask: vi.fn(), updateTaskStatus: vi.fn(), appendTaskEvent: vi.fn() } as never)
const ordinary = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run: vi.fn() }
const harness = createHarness(ordinary, undefined, service as unknown as Record<string, unknown>, true,
{ getRuntime: vi.fn(async () => ordinary) })
const expert = { id: crypto.randomUUID(), name: 'Remote writer', description: '', systemInstructions: 'Work in the project.',
modelProfileId: expertProfileId, routingKeywords: ['write'], enabled: true, createdAt: '', updatedAt: '' }
harness.getResolvedSettings.mockResolvedValue(settings)
harness.assistantDatabase.getProject.mockReturnValue({ id: projectId, rootPath: globalRoot,
executionSpace: { kind: 'ssh', hostId: executionSpace.hostId, remoteRootPath: executionSpace.remoteRootPath },
runtimeSelection: { provider } })
harness.assistantDatabase.getConversation.mockReturnValue({ id: 'remote-expert', messages: [] })
harness.contextManager.enrichRequest.mockImplementation(request => ({ ...request, imageToolBinding: binding,
knowledgeCapabilityToken: 'parent-scoped-token', browserTabId }))
harness.assistantDatabase.getExpert.mockReturnValue(expert)
harness.assistantDatabase.listExperts.mockReturnValue([expert, { ...expert, id: crypto.randomUUID(), routingKeywords: [] }])
try {
const requestId = crypto.randomUUID()
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: 'remote-expert', projectId,
prompt: 'write a file', teamMode: true })
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
expect(factory).toHaveBeenCalledTimes(2)
expect(createRemote).toHaveBeenCalledWith({ provider, profileId: expert.modelProfileId }, executionSpace)
expect(createLocal).not.toHaveBeenCalled()
expect(binding.save).not.toHaveBeenCalled()
expect(fetcher).toHaveBeenCalledTimes(1)
expect(ordinary.run).not.toHaveBeenCalled()
expect(await readdir(globalRoot)).toEqual([])
} finally {
await harness.dispose()
resolveSpace.mockRestore()
vi.unstubAllGlobals()
runtimeFactoryMocks.createDefaultModelRuntime.mockReset()
await rm(globalRoot, { recursive: true, force: true })
}
})
it.each([
{ persisted: false, prompt: '请做资料分析' },
{ persisted: true, prompt: 'Unmatched request' }
])(
'falls back to the ordinary runtime for ineligible smart routing %#',
async ({ prompt, 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,
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,
smartRouting: true
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(run).toHaveBeenCalledOnce()
expect(subagentService.run).not.toHaveBeenCalled()
await harness.dispose()
}
)
it('allows ordinary text requests on runtimes without tool execution', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: false,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: AgentExecutionRequest) {
expect(request.trustedInstructions).not.toContain('Available GoodBuddy tools:')
expect(request.knowledgeCapabilityToken).toBeUndefined()
yield { requestId: request.requestId, type: 'text', delta: 'Text answer' }
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'Explain this code'
})
).resolves.toBeUndefined()
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
'3f496642-f47d-4e0a-8944-a32c77b0d6ef', 'completed'))
await harness.dispose()
})
it('executes delegated writes with enabled note and config grants and no authorizer', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const task = { id: crypto.randomUUID(), title: 'Delegated note', prompt: 'Create a note' }
let delivered: unknown
const server = createServer((request, response) => {
if (request.method === 'GET') {
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify(task))
} else {
let body = ''
request.on('data', chunk => { body += chunk })
request.on('end', () => { delivered = JSON.parse(body); response.end('{}') })
}
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as { port: number }
vi.stubEnv('GOODBUDDY_DELEGATION_ENDPOINT', `http://127.0.0.1:${address.port}`)
vi.stubEnv('GOODBUDDY_DELEGATION_TOKEN', 'test-token')
const gateway = {
grant: vi.fn(() => 'delegated-capability'),
getAvailableToolNames: vi.fn(() => ['note_create', 'goodbuddy_config_apply']),
revoke: vi.fn()
}
const config = { revokeRequest: vi.fn(), takePendingReload: vi.fn(() => 'none'), clear: vi.fn() }
const runtime = {
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
async *run(request: AgentExecutionRequest, _signal: AbortSignal, authorize?: unknown) {
expect(authorize).toBeUndefined()
expect(request).not.toHaveProperty('workMode')
expect(request.knowledgeCapabilityToken).toBe('delegated-capability')
database.createMagicNote({ title: task.title })
yield { requestId: request.requestId, type: 'tool', callId: 'delegated-note',
name: 'note_create', state: 'completed', summary: 'Created note' } as const
yield { requestId: request.requestId, type: 'done' } as const
}
}
let harness: ReturnType<typeof createHarness> | undefined
try {
harness = createHarness(runtime, undefined, undefined, false, undefined, undefined,
gateway, true, config, { getEnabledBuiltinMcpServerIds: vi.fn(async () => ['magic-notes', 'goodbuddy-config']) },
undefined, undefined, database)
harness.getResolvedSettings.mockResolvedValue({ workspacePath: process.cwd() })
await vi.waitFor(() => expect(delivered).toEqual({ status: 'completed' }))
expect(database.listMagicNotes().map(note => note.title)).toContain(task.title)
expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(expect.any(String), [], expect.any(AbortSignal),
'write', { access: 'write', workspacePath: process.cwd() })
expect(gateway.revoke).toHaveBeenCalledWith('delegated-capability')
} finally {
await harness?.dispose()
vi.unstubAllEnvs()
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
database.close()
}
})
it('routes Telegram settings and incoming messages through real channel handlers without modes', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-telegram-'))
const database = new AssistantDatabase(':memory:')
database.initialize(root)
database.ensureChannelProjects(root)
const routeConversation = vi.spyOn(database, 'getOrCreateRemoteConversation')
const settingsStore = new ChannelSettingsStore(join(root, 'channels.json'), {
isAvailable: () => true,
encrypt: value => Buffer.from(value),
decrypt: value => value.toString()
}, {})
const requests: AgentExecutionRequest[] = []
const runtime = {
capability: 'chat', supportsToolExecution: true,
async *run(request: AgentExecutionRequest, _signal: AbortSignal, authorize?: unknown) {
expect(authorize).toBeUndefined()
requests.push(request)
yield { requestId: request.requestId, type: 'text', delta: 'Telegram result' }
yield { requestId: request.requestId, type: 'done' }
}
}
let delivered = false
const replies: unknown[] = []
electronMocks.fetch.mockImplementation(async (input, init) => {
const method = String(input).split('/').at(-1)
let result: unknown
if (method === 'getMe') result = { id: 456, is_bot: true, username: 'ipc_test_bot' }
else if (method === 'getWebhookInfo') result = { url: '' }
else if (method === 'getUpdates') {
result = delivered ? [] : [{
update_id: 789,
message: {
message_id: 1, date: Math.floor(Date.now() / 1000),
from: { id: 123, is_bot: false }, chat: { id: 123, type: 'private' },
text: '/execute preserve this literal prefix'
}
}]
delivered = true
} else if (method === 'sendMessage') {
replies.push(JSON.parse(String(init?.body)))
result = { message_id: 2 }
} else if (method === 'sendChatAction') result = true
else throw new Error(`Unexpected Telegram method: ${method}`)
return new Response(JSON.stringify({ ok: true, result }))
})
let harness: ReturnType<typeof createHarness> | undefined
try {
harness = createHarness(runtime, undefined, undefined, false, undefined, undefined,
undefined, false, undefined, undefined, undefined, undefined, database,
undefined, undefined, undefined, undefined, undefined, settingsStore)
const event = trustedEvent(harness.webContents)
const get = electronMocks.handlers.get(ipcChannels.channelSettingsGet)!
const apply = electronMocks.handlers.get(ipcChannels.channelSettingsApply)!
const test = electronMocks.handlers.get(ipcChannels.channelSettingsTest)!
const settings = {
enabled: false, secret: { action: 'replace', value: '456:test-token' },
allowedSenderIds: ['123'], allowGroupMessages: false
}
await expect(test(event, { channel: 'telegram', settings })).resolves.toEqual({
channel: 'telegram', ok: true, botUsername: 'ipc_test_bot'
})
await expect(get(event)).resolves.toMatchObject({ telegram: { secretConfigured: false } })
expect(delivered).toBe(false)
await expect(Promise.resolve().then(() => test(event, {
channel: 'telegram', settings: { ...settings, allowedSenderIds: ['username'] }
}))).rejects.toThrow()
await expect(Promise.resolve().then(() => apply(event, { telegram: { ...settings, allowGroupMessages: true } }))).rejects.toThrow()
await expect(Promise.resolve().then(() => test({ sender: {}, senderFrame: harness!.webContents.mainFrame }, {
channel: 'telegram', settings
}))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(apply(event, { telegram: settings })).resolves.toMatchObject({
telegram: { enabled: false, secretConfigured: true, allowedSenderIds: ['123'] }
})
await expect(test(event, { channel: 'telegram' })).resolves.toEqual({
channel: 'telegram', ok: true, botUsername: 'ipc_test_bot'
})
await apply(event, { telegram: { ...settings, enabled: true, secret: { action: 'keep' } } })
await vi.waitFor(() => expect(replies).toContainEqual({ chat_id: '123', text: 'Telegram result' }))
expect(requests).toHaveLength(1)
const request = requests[0]!
const project = database.listProjects().find(item => item.channel === 'telegram')!
expect(project).toMatchObject({ kind: 'channel', name: 'Telegram' })
expect(routeConversation).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
projectId: project.id, channel: 'telegram', accountId: '456',
externalConversationId: '456:123', conversationType: 'direct'
}))
expect(database.getConversation(request.conversationId!)).toMatchObject({
projectId: project.id,
remote: { channel: 'telegram', conversationType: 'direct' },
messages: [
expect.objectContaining({ role: 'user', content: '/execute preserve this literal prefix' }),
expect.objectContaining({ role: 'assistant', content: 'Telegram result' })
]
})
expect(database.getTask(request.requestId)).toMatchObject({
projectId: project.id, conversationId: request.conversationId,
origin: 'delegation', status: 'completed', instructions: '/execute preserve this literal prefix'
})
expect(request.prompt).toContain('/execute preserve this literal prefix')
expect(request).not.toHaveProperty('workMode')
expect(request).not.toHaveProperty('mode')
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
channel: 'telegram', status: { state: 'starting' }
})
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
channel: 'telegram', status: { state: 'running' }
})
const history = database.getConversation(request.conversationId!)
await expect(apply(event, { telegram: { ...settings, secret: { action: 'clear' } } })).resolves.toMatchObject({
telegram: { enabled: false, secretConfigured: false, status: { state: 'disabled' } }
})
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
channel: 'telegram', status: { state: 'disabled' }
})
expect((await settingsStore.resolve('telegram')).secret).toBeUndefined()
expect(database.getProject(project.id)).toEqual(project)
expect(database.getConversation(request.conversationId!)).toEqual(history)
} finally {
await harness?.dispose()
electronMocks.fetch.mockReset()
database.close()
await rm(root, { recursive: true, force: true })
}
})
it('bridges explanation-only channel requests without an authorizer', async () => {
let received:
| {
request: {
requestId: string
conversationId: string
prompt: 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
},
_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
},
new AbortController().signal
)
).resolves.toEqual({
status: 'completed',
output: '只读结果'
})
expect(received?.request).toMatchObject({
prompt: expect.stringContaining('请只读分析')
})
expect(received?.authorize).toBeUndefined()
expect(harness.getPolicySettings).not.toHaveBeenCalled()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: '企业微信远程请求',
instructions: '请只读分析',
origin: 'delegation'
})
)
await harness.dispose()
})
it.each([
['weixin', '微信 ClawBot'],
['wecom', '企业微信'],
['dingtalk', '钉钉'],
['telegram', 'Telegram']
] as const)(
'grants enabled Magic Notes write tools to %s channel requests',
async (channel, channelLabel) => {
let receivedRequest:
| {
knowledgeCapabilityToken?: string
prompt: string
trustedInstructions?: string
}
| undefined
const runtime = {
capability: 'chat',
async *run(request: {
requestId: string
knowledgeCapabilityToken?: string
prompt: string
trustedInstructions?: 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,
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'
},
runtimeSelection: {
provider: 'model',
model: { kind: 'profile', 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
},
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-capability',
trustedInstructions: expect.stringContaining(
'note_list, note_get, note_search'
)
})
expect(receivedRequest?.prompt).toContain('读取我的笔记')
expect(receivedRequest).not.toHaveProperty('workMode')
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
},
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')
}
const outboxDatabase = new AssistantDatabase(':memory:')
outboxDatabase.initialize('C:\\Workspace')
const outbox = new SqliteChannelOutbox(outboxDatabase)
let inbound!: ChannelInboundHandler
const send = vi.fn<ChannelDriver['send']>(async () => undefined)
const service = new ChannelService({
channel: 'wecom',
start: (handler) => { inbound = handler },
send,
stop: () => undefined
}, executor, { allowedSenderIds: ['user-1'], outbox })
try {
await service.start()
await inbound(
{
channel: 'wecom',
eventId: 'event-generated-image',
senderId: 'user-1',
conversationId: 'conversation-generated-image',
conversationType: 'direct',
text: '生成结果图',
mentioned: false
},
() => undefined
)
await vi.waitFor(() => expect(send).toHaveBeenCalledOnce())
expect(send.mock.calls[0]?.[0]).toMatchObject({
status: 'completed',
attachments: [
{
name: '结果图.png',
mimeType: 'image/png',
size: image.byteLength,
kind: 'image',
dataBase64: image.toString('base64')
}
]
})
expect(send.mock.calls[0]?.[0]).not.toHaveProperty('artifactIds')
expect(outbox.listUndelivered()).toEqual([])
expect(
harness.assistantDatabase.appendRemoteConversationMessage
).toHaveBeenLastCalledWith(
expect.objectContaining({
role: 'assistant',
artifactIds: [
'00000000-0000-4000-8000-000000000499'
]
})
)
} finally {
await service.stop()
outboxDatabase.close()
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
},
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 unavailable',
getStatus: () =>
Promise.resolve({
id: 'model',
label: 'Direct model',
available: false,
supportsToolExecution: false,
detail: 'Model unavailable'
}),
expectedError:
'Model unavailable'
}
])(
'finalizes remote requests 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
},
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',
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 channel tools without a general authorizer and preserves old prefixes literally', async () => {
let receivedAuthorize: unknown
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; prompt: string },
_signal: AbortSignal,
authorize: unknown
) {
receivedAuthorize = authorize
expect(request.prompt).toContain('/execute 更新 README')
yield { requestId: request.requestId, type: 'tool', callId: 'channel-write',
name: 'workspace_apply_patch', state: 'completed', summary: 'Updated 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
},
new AbortController().signal,
reportProgress
)
).resolves.toEqual({
status: 'completed',
output: '执行完成'
})
expect(receivedAuthorize).toBeUndefined()
expect(reportProgress).not.toHaveBeenCalled()
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(
expect.any(String),
'waiting_approval'
)
// Channel conversations inherit the project's choice instead of copying it.
expect(
harness.assistantDatabase.getOrCreateRemoteConversation
).toHaveBeenCalledWith(
expect.not.objectContaining({ runtimeSelection: expect.anything() })
)
await harness.dispose()
})
it('grants Magic Notes write tools to channel requests', async () => {
let receivedRequest:
| {
knowledgeCapabilityToken?: string
trustedInstructions?: 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
}) {
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,
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
},
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',
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,
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
},
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('uses enabled runtime capabilities for remote Agent requests', async () => {
let receivedRequest:
| {
trustedInstructions?: string
}
| undefined
const selectedRuntime = {
runtimeId: 'opencode',
capability: 'chat',
supportsToolExecution: true,
async *run(request: {
requestId: string
trustedInstructions?: 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,
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'
},
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',
runtimeSelection: { provider: 'opencode' },
knowledgeLibraryIds: []
}
)
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
{ provider: 'opencode' },
expect.objectContaining({
kind: 'ssh',
remoteRootPath: '/srv/project'
})
)
expect(receivedRequest).not.toHaveProperty('workMode')
expect(receivedRequest?.trustedInstructions).toContain(
'enabled capabilities'
)
expect(receivedRequest?.trustedInstructions).toContain(
'Respect requests for explanation only'
)
expect(receivedRequest?.trustedInstructions).not.toContain(
'Do not call tools'
)
await harness.dispose()
})
it('routes channel requests to a configured Agent Runtime without an 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,
undefined,
false,
selectedRuntimes
)
harness.getResolvedSettings.mockResolvedValue({
subagentSmartRoutingEnabled: false,
continueModelProfile: { id: configuredProfileId },
modelProfiles: [{
id: configuredProfileId, baseUrl: 'https://models.example/v1',
protocol: 'openai-chat-completions', authentication: 'none'
}]
})
vi.mocked(
harness.assistantDatabase.listProjects
).mockReturnValue([
{
id: '00000000-0000-4000-8000-000000000401',
name: '企业微信',
description: '企业微信远程消息与受控任务',
rootPath: 'C:\\ProjectWorkspace',
executionSpace: {
kind: 'local',
rootPath: 'C:\\ProjectWorkspace'
},
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
},
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.not.objectContaining({ runtimeSelection: expect.anything() })
)
expect(receivedAuthorize).toBeUndefined()
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('runs direct-model tools without approval events or an authorizer', async () => {
let receivedAuthorize:
| ((
request: {
scopeKey: string
title: string
description: string
}
) => Promise<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
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: '读取文件'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(receivedAuthorize).toBeUndefined()
expect(harness.getPolicySettings).not.toHaveBeenCalled()
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('allows channel text-only models without tool execution support', async () => {
let receivedAuthorize: unknown
const runtime = {
runtimeId: 'model',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: false,
getStatus: vi.fn(async () => ({ available: true, supportsToolExecution: false })),
dispose: vi.fn(),
async *run(
request: AgentExecutionRequest,
_signal: AbortSignal,
authorize: unknown
) {
receivedAuthorize = authorize
expect(request.prompt).toContain('/ask Explain this code')
expect(request.trustedInstructions).not.toContain('Available GoodBuddy tools:')
yield { requestId: request.requestId, type: 'text', delta: 'Text answer' }
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
await expect(channelMocks.executor!({
channel: 'wecom', eventId: 'text-only', senderId: 'user-1',
conversationId: 'text-only', conversationType: 'direct',
text: '/ask Explain this code', mentioned: false
}, new AbortController().signal)).resolves.toEqual({ status: 'completed', output: 'Text answer' })
expect(receivedAuthorize).toBeUndefined()
expect(harness.getPolicySettings).not.toHaveBeenCalled()
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
expect(harness.webContents.send).not.toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({ type: 'approval' })
)
await harness.dispose()
})
it('grants local config write access without a standalone native authorizer', async () => {
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6e1'
const knowledgeGateway = {
grant: vi.fn(() => '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 }) {
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(
runtime,
undefined,
undefined,
false,
undefined,
undefined,
knowledgeGateway,
false,
goodbuddyConfigService
)
harness.getResolvedSettings.mockResolvedValue({
workspacePath: 'C:\\Workspace'
})
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: `conversation-${requestId}`,
prompt: '删除 MCP'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
requestId,
[],
expect.any(AbortSignal),
'none',
{ access: 'write', workspacePath: 'C:\\Workspace' }
)
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,
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'
})
}
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'
})
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 = { on: vi.fn(), removeListener: vi.fn(),
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,
{} 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 })
}
})
})