import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { ipcChannels } from '../shared/ipc-channels' import type { AssistantProject } from '../shared/assistant-contracts' import type { BrowserLiveState } from '../shared/contracts' import { AssistantDatabase } from './assistant/assistant-database' import { registerIpcHandlers } from './ipc' type InvokeHandler = (event: unknown, input?: unknown) => unknown const electronMocks = vi.hoisted(() => { const handlers = new Map() return { handlers, handle: vi.fn((channel: string, handler: InvokeHandler) => { handlers.set(channel, handler) }), removeHandler: vi.fn((channel: string) => { handlers.delete(channel) }), showOpenDialog: vi.fn(async () => ({ canceled: true, filePaths: [] as string[] })), showSaveDialog: vi.fn(async () => ({ canceled: true, filePath: undefined as string | undefined })), openPath: vi.fn(async () => ''), showItemInFolder: vi.fn(), openExternal: vi.fn(async () => undefined) } }) const channelMocks = vi.hoisted(() => ({ executor: undefined as | (( message: { channel: string eventId: string senderId: string conversationId: string conversationType: 'direct' | 'group' text: string mentioned: boolean workMode: 'ask' attachments?: Array<{ name: string mimeType: string size: number kind: 'image' | 'file' dataBase64: string }> attachmentError?: string }, signal: AbortSignal, reportProgress?: (result: { status: string output?: string error?: string }) => Promise ) => Promise<{ status: string output?: string error?: string attachments?: Array<{ name: string mimeType: string size: number kind: 'image' | 'file' dataBase64: string }> }>) | undefined, stop: vi.fn(async () => undefined) })) const runtimeFactoryMocks = vi.hoisted(() => ({ createModelProfileRuntime: vi.fn(), createDefaultModelRuntime: vi.fn() })) describe('registerIpcHandlers computer capabilities', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() channelMocks.stop.mockResolvedValue(undefined) }) it('validates computer capability requests and restricts them to the trusted renderer', async () => { const webContents = { mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html'), 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), setComputerCapabilityEnabled: vi.fn(async () => snapshot), createBrowserProfile: vi.fn(async () => snapshot), diagnoseComputerCapability: vi.fn(async () => ({ capabilityId: 'host-browser-control', status: 'disabled', checkedAt: '2026-08-05T00:00:00.000Z', checks: [] })) } const onRuntimeSettingsChanged = vi.fn(async () => {}) const interact = vi.fn(async () => {}) const releaseConversation = vi.fn(async () => {}) let browserStateListener: | ((state: BrowserLiveState) => void) | undefined const dispose = registerIpcHandlers( window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', {} as never, capabilityService as never, { clear: vi.fn() } as never, {} as never, { claimDueSchedules: vi.fn(() => []) } as never, { clear: vi.fn() } as never, {} as never, onRuntimeSettingsChanged, undefined, { interact, releaseConversation, onState: (listener) => { browserStateListener = listener return vi.fn() } } ) const event = { sender: webContents, senderFrame: webContents.mainFrame } 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() electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: ['C:\\meeting-helper.zip'] }) await expect( electronMocks.handlers.get( ipcChannels.capabilitiesImportSkill )?.(event, 'zip') ).resolves.toEqual(snapshot) expect(electronMocks.showOpenDialog).toHaveBeenCalledWith( window, expect.objectContaining({ properties: ['openFile'], filters: [{ name: 'Skill ZIP', extensions: ['zip'] }] }) ) expect(capabilityService.importSkill).toHaveBeenCalledWith( 'C:\\meeting-helper.zip' ) await expect( electronMocks.handlers.get( ipcChannels.capabilitiesImportSkill )?.(event, 'unsupported') ).rejects.toThrow() browserStateListener?.({ conversationId: 'browser-conversation', status: 'ready', updatedAt: 1 }) expect(webContents.send).toHaveBeenCalledWith( ipcChannels.browserState, expect.objectContaining({ conversationId: 'browser-conversation', status: 'ready' }) ) await expect( electronMocks.handlers.get(ipcChannels.browserStop)?.(event, { conversationId: 'browser-conversation' }) ).resolves.toBeUndefined() expect(releaseConversation).toHaveBeenCalledWith( 'browser-conversation' ) await expect( electronMocks.handlers.get(ipcChannels.browserInteract)?.(event, { conversationId: 'browser-conversation' }) ).resolves.toBeUndefined() expect(interact).toHaveBeenCalledWith( 'browser-conversation', expect.any(AbortSignal) ) expect(() => electronMocks.handlers.get( ipcChannels.capabilitiesCreateBrowserProfile )?.(event, { name: '工作配置', executable: 'C:\\unsafe.exe' }) ).toThrow() expect(capabilityService.createBrowserProfile).not.toHaveBeenCalled() expect(() => electronMocks.handlers.get( ipcChannels.capabilitiesDiagnoseComputer )?.( { sender: {}, senderFrame: webContents.mainFrame }, 'host-browser-control' ) ).toThrow('拒绝来自未知窗口的 IPC 请求') await dispose() }) }) vi.mock('electron', () => ({ app: { getName: vi.fn(() => 'GoodBuddy'), getVersion: vi.fn(() => '0.1.0') }, BrowserWindow: class {}, dialog: { showOpenDialog: electronMocks.showOpenDialog, showSaveDialog: electronMocks.showSaveDialog }, ipcMain: { handle: electronMocks.handle, removeHandler: electronMocks.removeHandler }, Notification: class { static isSupported(): boolean { return false } }, shell: { openPath: electronMocks.openPath, showItemInFolder: electronMocks.showItemInFolder, openExternal: electronMocks.openExternal } })) vi.mock('./assistant/heartbeat-service', () => ({ HeartbeatService: class { async processDue(): Promise {} } })) vi.mock('./agent/create-runtime', () => runtimeFactoryMocks) vi.mock('./channels/channel-env', () => ({ isReadOnlyChannelMessage: (message: { workMode: string }) => message.workMode === 'ask', startEnvironmentChannels: vi.fn( (options: { executor: typeof channelMocks.executor }) => { channelMocks.executor = options.executor return [ { start: vi.fn(async () => undefined), stop: channelMocks.stop } ] } ) })) describe('registerIpcHandlers model ZIP dialogs', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('imports and exports speech and OCR ZIPs through trusted dialogs', async () => { const webContents = { mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn() } const window = { webContents, isDestroyed: vi.fn(() => false), isMaximized: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() } const event = { sender: webContents, senderFrame: webContents.mainFrame } const speechSnapshot = { catalog: [], installed: [], operations: [] } const speechModelManager = { rootDirectory: 'C:\\models\\speech', importArchive: vi.fn(async () => speechSnapshot), exportArchive: vi.fn(async () => undefined), getSnapshot: vi.fn(async () => speechSnapshot), cancel: vi.fn() } const ocrSnapshot = { settings: {}, models: { catalog: [], installed: [], operations: [] } } const documentParsingService = { snapshot: vi.fn(async () => ocrSnapshot) } const documentOcrModelManager = { importArchive: vi.fn(async () => undefined), exportArchive: vi.fn(async () => undefined) } const dispose = registerIpcHandlers( window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', {} as never, {} as never, { clear: vi.fn() } as never, {} as never, { claimDueSchedules: vi.fn(() => []) } as never, { clear: vi.fn() } as never, {} as never, vi.fn(async () => undefined), undefined, undefined, undefined, undefined, undefined, undefined, speechModelManager as never, undefined, undefined, undefined, undefined, undefined, documentParsingService as never, documentOcrModelManager as never ) await expect( electronMocks.handlers.get( ipcChannels.speechModelsImportArchive )?.(event, { modelId: 'speech-model' }) ).resolves.toBeUndefined() expect(speechModelManager.importArchive).not.toHaveBeenCalled() electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: ['C:\\transfer\\speech.zip'] }) await expect( electronMocks.handlers.get( ipcChannels.speechModelsImportArchive )?.(event, { modelId: 'speech-model' }) ).resolves.toBe(speechSnapshot) expect(electronMocks.showOpenDialog).toHaveBeenLastCalledWith( window, expect.objectContaining({ properties: ['openFile'], filters: [ { name: 'GoodBuddy 模型 ZIP', extensions: ['zip'] } ] }) ) expect(speechModelManager.importArchive).toHaveBeenCalledWith( 'speech-model', 'C:\\transfer\\speech.zip' ) electronMocks.showSaveDialog.mockResolvedValueOnce({ canceled: false, filePath: 'C:\\transfer\\speech-model' }) await expect( electronMocks.handlers.get( ipcChannels.speechModelsExportArchive )?.(event, { modelId: 'speech-model' }) ).resolves.toBe(speechSnapshot) expect(speechModelManager.exportArchive).toHaveBeenCalledWith( 'speech-model', 'C:\\transfer\\speech-model.zip' ) electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: ['C:\\transfer\\ocr.zip'] }) await expect( electronMocks.handlers.get( ipcChannels.documentOcrModelsImportArchive )?.(event, { modelId: 'ocr-model' }) ).resolves.toBe(ocrSnapshot) expect(documentOcrModelManager.importArchive).toHaveBeenCalledWith( 'ocr-model', 'C:\\transfer\\ocr.zip' ) electronMocks.showSaveDialog.mockResolvedValueOnce({ canceled: false, filePath: 'C:\\transfer\\ocr-model.ZIP' }) await expect( electronMocks.handlers.get( ipcChannels.documentOcrModelsExportArchive )?.(event, { modelId: 'ocr-model' }) ).resolves.toBe(ocrSnapshot) expect(documentOcrModelManager.exportArchive).toHaveBeenCalledWith( 'ocr-model', 'C:\\transfer\\ocr-model.ZIP' ) await expect( electronMocks.handlers.get( ipcChannels.speechModelsExportArchive )?.( { sender: {}, senderFrame: webContents.mainFrame }, { modelId: 'speech-model' } ) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') await expect( electronMocks.handlers.get( ipcChannels.documentOcrModelsImportArchive )?.(event, {}) ).rejects.toThrow() await dispose() }) }) describe('registerIpcHandlers connection tests', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('tests a resolved model profile without invoking the selected Continue Runtime', async () => { const profileId = '00000000-0000-4000-8000-000000000001' const profile = { id: profileId, name: '默认模型', baseUrl: 'https://models.example', modelName: 'good-model', protocol: 'anthropic-messages', authentication: 'api-key', imageGenerationQuality: 'auto', apiKey: 'main-only-secret' as string | undefined } const resolvedSettings = { provider: 'continue', workspacePath: 'C:\\Workspace', modelProfiles: [profile], defaultModelProfileId: profileId } const modelRuntime = { testConnection: vi.fn(async () => ({ id: 'model', label: 'good-model', available: true, supportsToolExecution: true, detail: 'Ready' })), getStatus: vi.fn(), dispose: vi.fn(async () => undefined) } runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue( modelRuntime ) const continueRuntime = { testConnection: vi.fn(async () => { throw new Error('Continue 配置不可用') }), getStatus: vi.fn(), dispose: vi.fn(async () => undefined) } const getResolvedSettings = vi.fn(async () => resolvedSettings) const webContents = { mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn() } const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() } const contextManager = { clear: vi.fn() } const approvalBroker = { clear: vi.fn() } const dispose = registerIpcHandlers( window as never, continueRuntime as never, 'CommandOrControl+Shift+Space', { getResolvedSettings } as never, {} as never, contextManager as never, {} as never, { claimDueSchedules: vi.fn(() => []) } as never, approvalBroker as never, {} as never, vi.fn(async () => {}) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsTestModel )?.(event, profileId) ).resolves.toMatchObject({ id: 'model', label: 'good-model', available: true }) expect(getResolvedSettings).toHaveBeenCalledOnce() expect( runtimeFactoryMocks.createModelProfileRuntime ).toHaveBeenCalledWith('C:\\Workspace', resolvedSettings, profile) expect(modelRuntime.testConnection).toHaveBeenCalledOnce() expect(modelRuntime.dispose).toHaveBeenCalledOnce() expect(continueRuntime.testConnection).not.toHaveBeenCalled() await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsTestModel )?.(event, 'not-a-profile-id') ).rejects.toThrow() expect(getResolvedSettings).toHaveBeenCalledOnce() getResolvedSettings.mockResolvedValueOnce({ ...resolvedSettings, modelProfiles: [{ ...profile, apiKey: undefined }] }) runtimeFactoryMocks.createModelProfileRuntime.mockClear() modelRuntime.testConnection.mockClear() await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsTestModel )?.(event, profileId) ).rejects.toThrow('模型连接“默认模型”未配置 API Key') expect(getResolvedSettings).toHaveBeenCalledTimes(2) expect( runtimeFactoryMocks.createModelProfileRuntime ).not.toHaveBeenCalled() expect(modelRuntime.testConnection).not.toHaveBeenCalled() const noAuthProfile = { ...profile, authentication: 'none', apiKey: undefined } const noAuthSettings = { ...resolvedSettings, modelProfiles: [noAuthProfile] } getResolvedSettings.mockResolvedValueOnce(noAuthSettings) await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsTestModel )?.(event, profileId) ).resolves.toMatchObject({ available: true }) expect( runtimeFactoryMocks.createModelProfileRuntime ).toHaveBeenCalledWith( 'C:\\Workspace', noAuthSettings, noAuthProfile ) expect(modelRuntime.testConnection).toHaveBeenCalledOnce() await dispose() }) it('validates and tests the selected OpenCode or Continue Runtime', async () => { const selectedRuntimes = { getRuntime: vi.fn(), getStatus: vi.fn(), testStatus: vi.fn(async () => ({ id: 'opencode', label: 'OpenCode', available: true, supportsToolExecution: true, detail: 'Ready' })), releaseConversation: vi.fn(async () => undefined) } const fallbackRuntime = { testConnection: vi.fn(async () => { throw new Error('不应测试旧的全局 Runtime') }), getStatus: vi.fn(), dispose: vi.fn(async () => undefined) } const webContents = { mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn() } const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() } const dispose = registerIpcHandlers( window as never, fallbackRuntime as never, 'CommandOrControl+Shift+Space', {} as never, {} as never, { clear: vi.fn() } as never, {} as never, { claimDueSchedules: vi.fn(() => []) } as never, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, selectedRuntimes as never ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const selection = { provider: 'opencode' as const, profileId: '00000000-0000-4000-8000-000000000001' } await expect( electronMocks.handlers.get(ipcChannels.runtimeSettingsTest)?.( event, selection ) ).resolves.toMatchObject({ id: 'opencode', available: true }) expect(selectedRuntimes.testStatus).toHaveBeenCalledWith(selection) expect(fallbackRuntime.testConnection).not.toHaveBeenCalled() await expect( electronMocks.handlers.get(ipcChannels.runtimeSettingsTest)?.( event, { provider: 'opencode', profileId: 'not-a-uuid' } ) ).rejects.toThrow() expect(selectedRuntimes.testStatus).toHaveBeenCalledOnce() await dispose() }) }) describe('registerIpcHandlers Runtime config actions', () => { const temporaryDirectories: string[] = [] afterEach(async () => { electronMocks.handlers.clear() vi.clearAllMocks() await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) it('opens only configured files or the fixed Runtime config directory', async () => { const temporaryDirectory = await mkdtemp( join(tmpdir(), 'goodbuddy-runtime-config-') ) temporaryDirectories.push(temporaryDirectory) const configPath = join(temporaryDirectory, 'config.yaml') await writeFile(configPath, 'name: Test', 'utf8') const getPublicSettings = vi.fn(async () => ({ opencodeConfigPath: '', continueConfigPath: configPath })) const webContents = { mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn() } const window = { webContents, isDestroyed: vi.fn(() => false), isMaximized: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() } const dispose = registerIpcHandlers( window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', { getPublicSettings } as never, {} as never, { clear: vi.fn() } as never, {} as never, { claimDueSchedules: vi.fn(() => []) } as never, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const canonicalConfigPath = await realpath(configPath) await electronMocks.handlers.get( ipcChannels.runtimeSettingsOpenConfig )?.(event, { runtime: 'continue', action: 'open-file' }) expect(electronMocks.openPath).toHaveBeenCalledWith( canonicalConfigPath ) await electronMocks.handlers.get( ipcChannels.runtimeSettingsOpenConfig )?.(event, { runtime: 'continue', action: 'show-file' }) expect(electronMocks.showItemInFolder).toHaveBeenCalledWith( canonicalConfigPath ) await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsOpenConfig )?.(event, { runtime: 'continue', action: 'open-file', path: join(temporaryDirectory, 'attacker-controlled.yaml') }) ).rejects.toThrow() expect(getPublicSettings).toHaveBeenCalledTimes(2) getPublicSettings.mockResolvedValueOnce({ opencodeConfigPath: '', continueConfigPath: 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 void>() const webContents = { mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn() } const window = { webContents, isDestroyed: vi.fn(() => false), isMaximized: vi.fn(() => maximized), minimize: vi.fn(), maximize: vi.fn(() => { maximized = true }), unmaximize: vi.fn(() => { maximized = false }), close: vi.fn(), on: vi.fn((name: string, listener: () => void) => { listeners.set(name, listener) }), removeListener: vi.fn() } const dispose = registerIpcHandlers( window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', {} as never, {} as never, { clear: vi.fn() } as never, {} as never, { claimDueSchedules: vi.fn(() => []) } as never, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } electronMocks.handlers.get(ipcChannels.windowMinimize)?.(event) electronMocks.handlers.get( ipcChannels.windowToggleMaximize )?.(event) listeners.get('maximize')?.() electronMocks.handlers.get(ipcChannels.windowClose)?.(event) expect(window.minimize).toHaveBeenCalledOnce() expect(window.maximize).toHaveBeenCalledOnce() expect(webContents.send).toHaveBeenCalledWith( ipcChannels.windowMaximizedChanged, true ) expect(window.close).toHaveBeenCalledOnce() expect(() => electronMocks.handlers .get(ipcChannels.windowIsMaximized) ?.({ sender: {}, senderFrame: webContents.mainFrame }) ).toThrow('拒绝来自未知窗口的 IPC 请求') await dispose() expect(window.removeListener).toHaveBeenCalledWith( 'maximize', listeners.get('maximize') ) }) }) describe('registerIpcHandlers workspace files', () => { const temporaryDirectories: string[] = [] afterEach(async () => { electronMocks.handlers.clear() vi.clearAllMocks() await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) it('resolves the project root and validates file requests', async () => { const rootPath = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-files-')) temporaryDirectories.push(rootPath) await writeFile(join(rootPath, 'README.md'), '# GoodBuddy\n') const projectId = '00000000-0000-4000-8000-000000000101' const assistantDatabase = { claimDueSchedules: vi.fn(() => []), getProject: vi.fn(() => ({ id: projectId, rootPath })) } const webContents = { mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html') } const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() } const dispose = registerIpcHandlers( window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', {} as never, {} as never, { clear: vi.fn() } as never, {} as never, assistantDatabase as never, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const list = await electronMocks.handlers.get( ipcChannels.workspaceDirectoryList )?.(event, { projectId, path: '' }) const preview = await electronMocks.handlers.get( ipcChannels.workspaceFileRead )?.(event, { projectId, path: 'README.md' }) expect(list).toMatchObject({ entries: [ { name: 'README.md', path: 'README.md', type: 'file' } ] }) expect(preview).toMatchObject({ path: 'README.md', content: '# GoodBuddy\n', mimeType: 'text/markdown' }) await expect( electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.(event, { projectId, path: '../outside.txt' }) ).rejects.toThrow('路径必须是工作区内的相对路径') expect(assistantDatabase.getProject).toHaveBeenCalledWith(projectId) await dispose() }) }) describe('registerIpcHandlers token usage', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('returns the database token summary to a trusted renderer', async () => { const summary = { totals: { callCount: 2, input: 120, output: 30, cacheRead: 10, cacheWrite: 5, totalTokens: 165 }, records: [] } const assistantDatabase = { claimDueSchedules: vi.fn(() => []), getTokenUsageSummary: vi.fn(() => summary) } const webContents = { mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html') } const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() } const dispose = registerIpcHandlers( window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', {} as never, {} as never, { clear: vi.fn() } as never, {} as never, assistantDatabase as never, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) ) const handler = electronMocks.handlers.get( ipcChannels.tokenUsageSummary ) expect(handler).toBeDefined() expect( handler?.({ sender: webContents, senderFrame: webContents.mainFrame }) ).toBe(summary) expect(assistantDatabase.getTokenUsageSummary).toHaveBeenCalledOnce() await dispose() }) }) describe('registerIpcHandlers agent terminal state', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) function createHarness( runtime: Record, onBeforeClearLocalData?: () => Promise, toolApproval: 'always' | 'policy' = 'always', subagentService?: Record, smartRoutingEnabled = false, selectedRuntimes?: Record, knowledgeServiceOverride?: Record, knowledgeGateway?: Record, magicNotesEnabled = false ) { const assistantDatabase = { claimDueSchedules: vi.fn(() => []), createTask: vi.fn(), appendTaskEvent: vi.fn(), updateTaskStatus: vi.fn(), createTextArtifact: vi.fn(), createImageArtifact: vi.fn(() => ({ id: '00000000-0000-4000-8000-000000000499', title: '生成图片' })), listProjects: vi.fn<() => AssistantProject[]>(() => [ { id: '00000000-0000-4000-8000-000000000401', name: '企业微信', description: '企业微信远程消息与受控任务', rootPath: 'C:\\ProjectWorkspace', defaultWorkMode: 'ask', runtimeSelection: { provider: 'model', profileId: '00000000-0000-4000-8000-000000000001' }, kind: 'channel', channel: 'wecom', status: 'active', createdAt: '2026-08-04T00:00:00.000Z', updatedAt: '2026-08-04T00:00:00.000Z' } ]), getOrCreateRemoteConversation: vi.fn(() => ({ id: '00000000-0000-4000-8000-000000000402', projectId: '00000000-0000-4000-8000-000000000401', title: '企业微信 · ****er-1', updatedAt: Date.now(), messages: [] })), appendRemoteConversationMessage: vi.fn(), upsertModelUsageCall: vi.fn(), clearAssistantData: vi.fn(), listExperts: vi.fn<() => Array>>(() => []), getExpert: vi.fn(), getProject: vi.fn((projectId: string) => ({ id: projectId, rootPath: 'C:\\ProjectWorkspace' })) } const webContents = { mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn() } const window = { webContents, isDestroyed: vi.fn(() => false), isFocused: vi.fn(() => true), on: vi.fn(), removeListener: vi.fn() } const contextManager = { enrichRequest: vi.fn((request) => request), ingestRemoteAttachment: vi.fn(async (attachment: { name: string size: number kind: 'image' | 'file' }) => ({ id: '00000000-0000-4000-8000-000000000498', name: attachment.name, size: attachment.size, preview: '远程附件', kind: attachment.kind === 'image' ? 'image' : 'text' })), remove: vi.fn(), clear: vi.fn() } const approvalBroker = { request: vi.fn(), respond: vi.fn(), clear: vi.fn() } const getResolvedSettings = vi.fn( async (): Promise> => ({ toolApproval, subagentSmartRoutingEnabled: smartRoutingEnabled }) ) const dispose = registerIpcHandlers( window as never, runtime as never, 'CommandOrControl+Shift+Space', { getResolvedSettings } as never, {} as never, contextManager as never, (knowledgeServiceOverride ?? { database: { listKnowledgeBases: vi.fn(() => []) } }) as never, assistantDatabase as never, approvalBroker as never, {} as never, vi.fn(async () => {}), onBeforeClearLocalData, undefined, subagentService as never, undefined, { get: vi.fn(async () => ({ magicNotesEnabled })) } as never, undefined, undefined, undefined, selectedRuntimes as never, undefined, knowledgeGateway as never ) return { approvalBroker, assistantDatabase, contextManager, dispose, getResolvedSettings, clearHandler: electronMocks.handlers.get( ipcChannels.appClearLocalData ), handler: electronMocks.handlers.get(ipcChannels.agentRun), statusHandler: electronMocks.handlers.get(ipcChannels.agentStatus), cancelHandler: electronMocks.handlers.get(ipcChannels.agentCancel), knowledgeSearchHandler: electronMocks.handlers.get( ipcChannels.knowledgeSearch ), webContents } } const trustedEvent = (webContents: { mainFrame: { url: string } }) => ({ sender: webContents, senderFrame: webContents.mainFrame }) 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'), drainReferences: vi.fn(() => []), revoke: vi.fn() } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, { database: { listKnowledgeBases: vi.fn(() => [ { id: libraryId, name: 'Known' } ]) } }, knowledgeGateway ) const event = trustedEvent(harness.webContents) await expect( harness.handler?.(event, { requestId: '00000000-0000-4000-8000-000000000021', conversationId: 'unknown-scope', prompt: 'test', workMode: 'ask', knowledgeLibraryIds: [ '22222222-2222-4222-8222-222222222222' ] }) ).rejects.toThrow('不存在的知识库') expect(knowledgeGateway.grant).not.toHaveBeenCalled() await harness.handler?.(event, { requestId: '00000000-0000-4000-8000-000000000022', conversationId: 'empty-scope', prompt: 'test', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( '00000000-0000-4000-8000-000000000022', 'completed' ) ) expect(knowledgeGateway.grant).not.toHaveBeenCalled() await harness.dispose() }) it('grants read-only Magic Notes tools in Ask and write tools in Execute', async () => { const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } } } const knowledgeGateway = { grant: vi.fn(() => 'capability'), drainReferences: vi.fn(() => []), revoke: vi.fn() } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, true ) const event = trustedEvent(harness.webContents) const askRequestId = '00000000-0000-4000-8000-000000000023' const executeRequestId = '00000000-0000-4000-8000-000000000024' await harness.handler?.(event, { requestId: askRequestId, conversationId: 'notes-read', prompt: '读取笔记', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( askRequestId, 'completed' ) ) await harness.handler?.(event, { requestId: executeRequestId, conversationId: 'notes-write', prompt: '创建笔记', workMode: 'execute', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( executeRequestId, 'completed' ) ) expect(knowledgeGateway.grant).toHaveBeenNthCalledWith( 1, askRequestId, [], expect.any(AbortSignal), 'read' ) expect(knowledgeGateway.grant).toHaveBeenNthCalledWith( 2, executeRequestId, [], expect.any(AbortSignal), 'write' ) await harness.dispose() }) it('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'), drainReferences: vi.fn(() => []), revoke: vi.fn() } const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, { database: { listKnowledgeBases } }, knowledgeGateway ) const requestId = '00000000-0000-4000-8000-000000000024' await expect( harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'later-library', prompt: 'search', workMode: 'ask', knowledgeLibraryIds: [libraries[100]!.id] }) ).resolves.toBeUndefined() await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect(listKnowledgeBases).toHaveBeenCalledWith(500) expect(knowledgeGateway.grant).toHaveBeenCalledWith( requestId, [libraries[100]!.id], expect.any(AbortSignal) ) 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'), drainReferences: vi.fn(() => [reference]), revoke: vi.fn() } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, { database: { listKnowledgeBases: vi.fn(() => [ { id: libraryId, name: 'Known' } ]) } }, knowledgeGateway ) const requestId = '00000000-0000-4000-8000-000000000023' await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'scoped', prompt: 'search', workMode: 'ask', knowledgeLibraryIds: [libraryId, libraryId] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect(knowledgeGateway.grant).toHaveBeenCalledWith( requestId, [libraryId], expect.any(AbortSignal) ) 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('returns no results for an explicitly empty knowledge search scope', async () => { const searchHybridMany = vi.fn(() => { throw new Error('must not search') }) const harness = createHarness( { capability: 'chat', supportsToolExecution: true }, undefined, 'always', undefined, false, undefined, { database: { listKnowledgeBases: vi.fn(() => []) }, searchHybridMany } ) await expect( harness.knowledgeSearchHandler?.( trustedEvent(harness.webContents), { libraryIds: [], query: 'anything' } ) ).resolves.toEqual([]) expect(searchHybridMany).not.toHaveBeenCalled() await harness.dispose() }) it('routes status and concurrent conversations to their selected runtimes', async () => { const firstProfileId = '00000000-0000-4000-8000-000000000001' const secondProfileId = '00000000-0000-4000-8000-000000000002' const firstSelection = { provider: 'model' as const, profileId: firstProfileId } const secondSelection = { provider: 'model' as const, profileId: secondProfileId } const firstRun = vi.fn() const secondRun = vi.fn() const createRuntime = ( label: string, run: typeof firstRun ): Record => ({ runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(async () => ({ id: 'model', label, available: true, supportsToolExecution: true })), dispose: vi.fn(async () => undefined), async *run(request: { requestId: string; conversationId: string }) { run(request) yield { requestId: request.requestId, type: 'done' } } }) const firstRuntime = createRuntime('model-one', firstRun) const secondRuntime = createRuntime('model-two', secondRun) const selectedRuntimes = { getStatus: vi.fn(async () => ({ id: 'model', label: 'model-two', available: true, supportsToolExecution: true })), getRuntime: vi.fn(async (selection: typeof firstSelection) => selection.profileId === firstProfileId ? firstRuntime : secondRuntime ), releaseConversation: vi.fn(async () => undefined) } const fallbackRuntime = { runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(async () => ({ id: 'model', label: 'fallback', available: true, supportsToolExecution: true })), run: vi.fn(), dispose: vi.fn(async () => undefined) } const harness = createHarness( fallbackRuntime, undefined, 'always', undefined, false, selectedRuntimes ) const event = trustedEvent(harness.webContents) await expect( harness.statusHandler?.(event, secondSelection) ).resolves.toEqual( expect.objectContaining({ label: 'model-two' }) ) expect(selectedRuntimes.getStatus).toHaveBeenCalledWith( secondSelection ) await Promise.all([ harness.handler?.(event, { requestId: '00000000-0000-4000-8000-000000000011', conversationId: 'conversation-one', projectId: '00000000-0000-4000-8000-000000000101', prompt: 'first request', workMode: 'ask', runtimeSelection: firstSelection }), harness.handler?.(event, { requestId: '00000000-0000-4000-8000-000000000012', conversationId: 'conversation-two', prompt: 'second request', workMode: 'ask', runtimeSelection: secondSelection }) ]) await vi.waitFor(() => { expect(firstRun).toHaveBeenCalledWith( expect.objectContaining({ conversationId: 'conversation-one', runtimeSelection: firstSelection }) ) expect(secondRun).toHaveBeenCalledWith( expect.objectContaining({ conversationId: 'conversation-two', runtimeSelection: secondSelection }) ) }) expect(fallbackRuntime.run).not.toHaveBeenCalled() expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith( firstSelection, 'C:\\ProjectWorkspace' ) expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith( secondSelection ) 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((resolve) => { markStarted = resolve }) const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run( _request: unknown, signal: AbortSignal ): AsyncGenerator { markStarted() await new Promise((_resolve, reject) => { signal.addEventListener( 'abort', () => { lifecycle.push('aborted') reject(signal.reason) }, { once: true } ) }) yield undefined as never } } const harness = createHarness(runtime, async () => { lifecycle.push('browser-cleared') }) vi.mocked( harness.assistantDatabase.clearAssistantData ).mockImplementation(() => { lifecycle.push('assistant-cleared') }) harness.handler?.(trustedEvent(harness.webContents), { requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', conversationId: 'conversation-clear', prompt: 'keep running', workMode: 'execute' }) await started await harness.clearHandler?.(trustedEvent(harness.webContents)) expect(lifecycle).toEqual([ 'aborted', 'browser-cleared', 'assistant-cleared' ]) await harness.dispose() }) it('marks a request failed when a tool fails before runtime done', async () => { const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'tool', callId: 'call-1', name: 'write', state: 'failed', summary: 'OpenCode 工具:write', error: 'write path denied' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: 'write a file', workMode: 'execute' }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'failed', 'write 工具执行失败:write path denied' ) ) expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith(requestId, 'completed') expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ requestId, type: 'error', status: 'failed', message: 'write 工具执行失败:write path denied' }) ) await harness.dispose() }) it('allows a completed request after a recoverable tool failure', async () => { const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'tool', callId: 'call-recoverable', name: '浏览器输入', state: 'recoverable', summary: '直连模型工具需要刷新后重试:浏览器输入' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: 'retry browser input', workMode: 'execute' }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith( requestId, 'failed', expect.any(String) ) await harness.dispose() }) it.each(['opencode', 'continue'] as const)( 'preserves read-only Ask mode at the %s Runtime boundary', async (runtimeId) => { let received: | { request: { workMode?: string } authorize: unknown } | undefined const runtime = { runtimeId, capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run( request: { requestId: string; workMode?: string }, _signal: AbortSignal, authorize: unknown ) { received = { request, authorize } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: 'run the task', workMode: 'ask' }) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) expect(received?.request.workMode).toBe('ask') expect(received?.authorize).toBeUndefined() expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect( harness.assistantDatabase.createTask ).toHaveBeenCalledWith( expect.objectContaining({ id: requestId, workMode: 'ask' }) ) await harness.dispose() } ) it.each(['model', 'opencode'] as const)( 'normalizes legacy interactive Plan requests to Ask for %s', async (runtimeId) => { let receivedRequest: | { requestId: string; prompt: string; workMode?: string } | undefined const runtime = { runtimeId, capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run(request: { requestId: string prompt: string workMode?: string }) { receivedRequest = request yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: 'draft a plan', workMode: 'plan' }) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) expect(receivedRequest?.workMode).toBe('ask') expect(receivedRequest?.prompt).toContain('Work mode: Ask.') expect(receivedRequest?.prompt).not.toContain('Work mode: Plan.') expect( harness.assistantDatabase.createTask ).toHaveBeenCalledWith( expect.objectContaining({ id: requestId, workMode: 'ask' }) ) await harness.dispose() } ) it('routes eligible Ask requests through the persisted smart expert service and publishes child events', async () => { const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), run: vi.fn() } const childTaskId = '00000000-0000-4000-8000-000000000099' const expert = { id: '00000000-0000-4000-8000-000000000001', name: '研究专家', description: '', systemInstructions: 'Analyze evidence.', routingKeywords: ['资料分析'], enabled: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z' } const subagentService = { run: vi.fn(async (input: { parentRequest: { requestId: string } onEvent: (event: Record) => void }) => { for (const state of ['queued', 'running', 'completed']) { input.onEvent({ requestId: input.parentRequest.requestId, type: 'subagent', childTaskId, expertId: expert.id, expertName: expert.name, routingMode: 'smart', state }) } return { childTaskId, output: '专家结果' } }), cancelAll: vi.fn(), dispose: vi.fn(async () => undefined) } const harness = createHarness( runtime, undefined, 'always', subagentService, true ) vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([ expert ]) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-smart', prompt: '请做资料分析', workMode: 'ask', smartRouting: true }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect(runtime.run).not.toHaveBeenCalled() expect(subagentService.run).toHaveBeenCalledWith( expect.objectContaining({ expert, routingMode: 'smart' }) ) expect(harness.assistantDatabase.appendTaskEvent).toHaveBeenCalledWith( requestId, 'subagent', expect.objectContaining({ childTaskId, state: 'queued' }) ) expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ type: 'subagent', state: 'completed' }) ) await harness.dispose() }) it.each([ { workMode: 'ask' as const, persisted: false }, { workMode: 'execute' as const, persisted: true } ])( 'falls back to the ordinary runtime for ineligible smart routing %#', async ({ workMode, persisted }) => { const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } } } const run = vi.spyOn(runtime, 'run') const subagentService = { run: vi.fn(), cancelAll: vi.fn(), dispose: vi.fn(async () => undefined) } const harness = createHarness( runtime, undefined, 'always', subagentService, persisted ) vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([ { id: '00000000-0000-4000-8000-000000000001', name: '研究专家', description: '', systemInstructions: 'Analyze.', routingKeywords: ['资料分析'], enabled: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z' } ]) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-fallback', prompt: '请做资料分析', workMode, smartRouting: true }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect(run).toHaveBeenCalledOnce() expect(subagentService.run).not.toHaveBeenCalled() await harness.dispose() } ) it('does not fall back to the ordinary runtime after smart subagent cancellation', async () => { const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), run: vi.fn() } let markStarted!: () => void const started = new Promise((resolve) => { markStarted = resolve }) const subagentService = { run: vi.fn((input: { signal: AbortSignal }) => { markStarted() return new Promise((_resolve, reject) => { input.signal.addEventListener( 'abort', () => reject(input.signal.reason), { once: true } ) }) }), cancelAll: vi.fn(), dispose: vi.fn(async () => undefined) } const harness = createHarness( runtime, undefined, 'always', subagentService, true ) vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([ { id: '00000000-0000-4000-8000-000000000001', name: '研究专家', description: '', systemInstructions: 'Analyze.', routingKeywords: ['资料分析'], enabled: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z' } ]) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-cancel-smart', prompt: '请做资料分析', workMode: 'ask', smartRouting: true }) await started harness.cancelHandler?.(trustedEvent(harness.webContents), requestId) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'cancelled', '请求已取消' ) ) expect(runtime.run).not.toHaveBeenCalled() await harness.dispose() }) it('rejects Execute before creating a task on an unsupported runtime', async () => { const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose: vi.fn(), run: vi.fn() } const harness = createHarness(runtime) await expect( harness.handler?.(trustedEvent(harness.webContents), { requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', conversationId: 'conversation-1', prompt: 'write a file', workMode: 'execute' }) ).rejects.toThrow('当前 Runtime 不支持工具执行') expect(harness.assistantDatabase.createTask).not.toHaveBeenCalled() await harness.dispose() }) it('bridges channel ask requests to read-only tasks without approval', async () => { let received: | { request: { requestId: string conversationId: string prompt: string workMode: string } authorize?: (request: { scopeKey: string title: string description: string }) => Promise } | undefined const runtime = { capability: 'chat', async *run( request: { requestId: string conversationId: string prompt: string workMode: string }, _signal: AbortSignal, authorize?: (request: { scopeKey: string title: string description: string }) => Promise ) { received = { request, authorize } yield { requestId: request.requestId, type: 'text', delta: '只读结果' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } await expect( executor( { channel: 'wecom', eventId: 'event-1', senderId: 'user-1', conversationId: 'conversation-1', conversationType: 'direct', text: '请只读分析', mentioned: false, workMode: 'ask' }, new AbortController().signal ) ).resolves.toEqual({ status: 'completed', output: '只读结果' }) expect(received?.request).toMatchObject({ workMode: 'ask', prompt: expect.stringContaining('请只读分析') }) await expect( received?.authorize?.({ scopeKey: 'model:builtin:workspace_read_text', title: '读取文件', description: '不应申请批准' }) ).resolves.toBe('deny') expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith( expect.objectContaining({ title: '企业微信远程请求', instructions: '请只读分析', workMode: 'ask', origin: 'delegation' }) ) await harness.dispose() }) it('persists remote media and passes it through the existing context path', async () => { let receivedRequest: | { contextIds?: string[] prompt: string } | undefined const runtime = { capability: 'chat', async *run(request: { requestId: string contextIds?: string[] prompt: string }) { receivedRequest = request yield { requestId: request.requestId, type: 'text', delta: '图片已分析' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } await expect( executor( { channel: 'wecom', eventId: 'event-media', senderId: 'user-1', conversationId: 'conversation-media', conversationType: 'direct', text: '', attachments: [ { name: '现场.png', mimeType: 'image/png', size: 4, kind: 'image', dataBase64: 'iVBORw==' } ], mentioned: false, workMode: 'ask' }, new AbortController().signal ) ).resolves.toEqual({ status: 'completed', output: '图片已分析' }) expect( harness.contextManager.ingestRemoteAttachment ).toHaveBeenCalledWith( expect.objectContaining({ name: '现场.png' }) ) expect(receivedRequest?.contextIds).toEqual([ '00000000-0000-4000-8000-000000000498' ]) expect( harness.assistantDatabase.appendRemoteConversationMessage ).toHaveBeenCalledWith( expect.objectContaining({ role: 'user', content: '请分析我发送的附件。', attachments: [ expect.objectContaining({ name: '现场.png' }) ] }) ) expect(harness.contextManager.remove).toHaveBeenCalledWith( '00000000-0000-4000-8000-000000000498' ) await harness.dispose() }) it('returns a generated image only as a current-task channel attachment', async () => { const image = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]) const runtime = { capability: 'image-generation', async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'generated-image', mimeType: 'image/png', data: image.toString('base64'), title: '结果图' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } await expect( executor( { channel: 'wecom', eventId: 'event-generated-image', senderId: 'user-1', conversationId: 'conversation-generated-image', conversationType: 'direct', text: '生成结果图', mentioned: false, workMode: 'ask' }, new AbortController().signal ) ).resolves.toMatchObject({ status: 'completed', attachments: [ { name: '结果图.png', mimeType: 'image/png', size: image.byteLength, kind: 'image', dataBase64: image.toString('base64') } ], artifactIds: [ '00000000-0000-4000-8000-000000000499' ] }) expect( harness.assistantDatabase.appendRemoteConversationMessage ).toHaveBeenLastCalledWith( expect.objectContaining({ role: 'assistant', artifactIds: [ '00000000-0000-4000-8000-000000000499' ] }) ) await harness.dispose() }) it('creates a bounded result file only when the remote user explicitly requests one', async () => { const runtime = { capability: 'chat', async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'text', delta: '# 本周报告\n\n已完成。' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } const result = await executor( { channel: 'wecom', eventId: 'event-result-file', senderId: 'user-1', conversationId: 'conversation-result-file', conversationType: 'direct', text: '请生成一个文件,总结本周进展', mentioned: false, workMode: 'ask' }, new AbortController().signal ) expect(result).toMatchObject({ status: 'completed', output: '# 本周报告\n\n已完成。', attachments: [ { name: 'GoodBuddy-结果.md', mimeType: 'text/markdown', kind: 'file' } ] }) expect( Buffer.from( result.attachments?.[0]?.dataBase64 ?? '', 'base64' ).toString('utf8') ).toBe('# 本周报告\n\n已完成。') await harness.dispose() }) it('runs remote Execute immediately with the selected direct model policy', async () => { let authorization: string | undefined const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, getStatus: vi.fn(async () => ({ id: 'model', label: 'Direct model', available: true, supportsToolExecution: true })), async *run( request: { requestId: string }, _signal: AbortSignal, authorize: ( request: { scopeKey: string title: string description: string } ) => Promise ) { authorization = await authorize({ scopeKey: 'model:builtin:workspace_write_text', title: '写入文件', description: '写入 README.md' }) yield { requestId: request.requestId, type: 'text', delta: '执行完成' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } const reportProgress = vi.fn(async () => undefined) await expect( executor( { channel: 'wecom', eventId: 'event-execute-direct', senderId: 'user-1', conversationId: 'conversation-execute-direct', conversationType: 'direct', text: '/execute 更新 README', mentioned: false, workMode: 'ask' }, new AbortController().signal, reportProgress ) ).resolves.toEqual({ status: 'completed', output: '执行完成' }) expect(authorization).toBe('once') expect(reportProgress).not.toHaveBeenCalled() expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith( expect.any(String), 'waiting_approval' ) expect( harness.assistantDatabase.getOrCreateRemoteConversation ).toHaveBeenCalledWith( expect.objectContaining({ runtimeSelection: { provider: 'model', profileId: '00000000-0000-4000-8000-000000000001' } }) ) await harness.dispose() }) it('routes remote Execute to a configured Agent Runtime without a GoodBuddy approval callback', async () => { let receivedAuthorize: unknown = 'not-called' const configuredProfileId = '00000000-0000-4000-8000-000000000019' const selectedRuntime = { runtimeId: 'continue', capability: 'chat', supportsToolExecution: true, getStatus: vi.fn(async () => ({ id: 'continue', label: 'Continue', available: true, supportsToolExecution: true })), async *run( request: { requestId: string }, _signal: AbortSignal, authorize?: unknown ) { receivedAuthorize = authorize yield { requestId: request.requestId, type: 'text', delta: 'Continue 已执行' } yield { requestId: request.requestId, type: 'done' } } } const selectedRuntimes = { getRuntime: vi.fn(async () => selectedRuntime), getStatus: vi.fn(), releaseConversation: vi.fn(async () => undefined) } const harness = createHarness( { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run: vi.fn() }, undefined, 'always', undefined, false, selectedRuntimes ) harness.getResolvedSettings.mockResolvedValue({ toolApproval: 'always', subagentSmartRoutingEnabled: false, continueModelProfile: { id: configuredProfileId } }) vi.mocked( harness.assistantDatabase.listProjects ).mockReturnValue([ { id: '00000000-0000-4000-8000-000000000401', name: '企业微信', description: '企业微信远程消息与受控任务', rootPath: 'C:\\ProjectWorkspace', defaultWorkMode: 'execute', runtimeSelection: { provider: 'continue' }, kind: 'channel', channel: 'wecom', status: 'active', createdAt: '2026-08-04T00:00:00.000Z', updatedAt: '2026-08-04T00:00:00.000Z' } ]) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } await expect( executor( { channel: 'wecom', eventId: 'event-execute-runtime', senderId: 'user-1', conversationId: 'conversation-execute-runtime', conversationType: 'direct', text: '更新 README', mentioned: false, workMode: 'ask' }, new AbortController().signal ) ).resolves.toEqual({ status: 'completed', output: 'Continue 已执行' }) expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith( { provider: 'continue', profileId: configuredProfileId }, 'C:\\ProjectWorkspace' ) expect( harness.assistantDatabase.getOrCreateRemoteConversation ).toHaveBeenCalledWith( expect.objectContaining({ runtimeSelection: { provider: 'continue' } }) ) expect(receivedAuthorize).toBeUndefined() expect(harness.approvalBroker.request).not.toHaveBeenCalled() await harness.dispose() }) it('stops channels before clearing other IPC resources', async () => { const order: string[] = [] channelMocks.stop.mockImplementationOnce(async () => { order.push('channel-stop') }) const harness = createHarness({ capability: 'chat', run: vi.fn() }) harness.contextManager.clear.mockImplementation(() => { order.push('context-clear') }) await harness.dispose() expect(order).toEqual(['channel-stop', 'context-clear']) }) it('authorizes direct-model Execute tools without approval events or broker prompts', async () => { let receivedAuthorize: | (( request: { scopeKey: string title: string description: string } ) => Promise) | undefined let decision: string | undefined const runtime = { runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run( request: { requestId: string }, _signal: AbortSignal, authorize: typeof receivedAuthorize ) { receivedAuthorize = authorize decision = await authorize?.({ scopeKey: 'model:builtin:workspace_read_text', title: '允许读取工作区文本?', description: '读取 README.md' }) yield { requestId: request.requestId, type: 'tool', callId: 'call-1', name: '读取工作区文本', state: 'completed', summary: '直连模型工具已完成' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: '读取文件', workMode: 'execute' }) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) expect(receivedAuthorize).toEqual(expect.any(Function)) expect(decision).toBe('once') expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith(requestId, 'waiting_approval') expect(harness.webContents.send).not.toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ type: 'approval' }) ) await harness.dispose() }) it('denies direct-model Execute tools when the deny-all policy is selected', async () => { let decision: string | undefined const runtime = { runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run( request: { requestId: string }, _signal: AbortSignal, authorize: ( request: { scopeKey: string title: string description: string } ) => Promise ) { decision = await authorize({ scopeKey: 'model:builtin:workspace_read_text', title: '允许读取工作区文本?', description: '读取 README.md' }) yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime, undefined, 'policy') const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: '读取文件', workMode: 'execute' }) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) expect(decision).toBe('deny') expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect(harness.webContents.send).not.toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ type: 'approval' }) ) await harness.dispose() }) it('preserves bounded runtime errors for persistence and renderer delivery', async () => { const fetchCause = Object.assign( new Error('connect ECONNREFUSED 127.0.0.1:11434'), { code: 'ECONNREFUSED', syscall: 'connect', address: '127.0.0.1', port: 11434 } ) const expectedError = [ 'fetch failed', 'cause:', 'connect ECONNREFUSED 127.0.0.1:11434', 'code: ECONNREFUSED', 'syscall: connect', 'address: 127.0.0.1', 'port: 11434' ].join('\n') const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose: vi.fn(), async *run() { yield* [] throw new TypeError('fetch failed', { cause: fetchCause }) } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: 'ask', workMode: 'ask' }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'failed', expectedError ) ) expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ message: expectedError }) ) await harness.dispose() }) }) describe('registerIpcHandlers Magic Notes analysis', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() channelMocks.stop.mockResolvedValue(undefined) }) it('persists comments and usage without exposing an analysis task', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-magic-ipc-')) const database = new AssistantDatabase( join(directory, 'assistant.sqlite') ) database.initialize('C:\\Workspace') const note = database.createMagicNote({ title: 'API 回归测试' }) const withEntry = database.createMagicNoteEntry({ noteId: note.id, content: { version: 1, ops: [{ insert: '请完成发布清单。\n' }] }, plainText: '请完成发布清单。' }) const entry = withEntry.entries[0]! const releaseConversation = vi.fn(async () => undefined) const disposeRuntime = vi.fn(async () => undefined) let analysisRequestId = '' const analysisRuntime = { releaseConversation, dispose: disposeRuntime, async *run(request: { requestId: string }) { analysisRequestId = request.requestId yield { requestId: request.requestId, type: 'model-usage', callId: 'magic-call-1', runtime: 'model', provider: 'openai', model: 'test-model', inputTokens: 20, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 } as const yield { requestId: request.requestId, type: 'text', delta: '{"comments":[{"kind":"suggestion","content":"先核对发布材料。"}]}' } as const yield { requestId: request.requestId, type: 'done' } as const } } runtimeFactoryMocks.createDefaultModelRuntime.mockReturnValue( analysisRuntime ) const webContents = { mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn() } const window = { webContents, isDestroyed: vi.fn(() => false), isMaximized: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() } const disposeHandlers = registerIpcHandlers( window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', { getResolvedSettings: vi.fn(async () => ({ workspacePath: 'C:\\Workspace' })) } as never, {} as never, { clear: vi.fn() } as never, {} as never, database, { clear: vi.fn() } as never, {} as never, vi.fn(async () => undefined) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } try { await expect( electronMocks.handlers.get(ipcChannels.magicNotesAnalyze)?.( event, { entryId: entry.id, requestId: '00000000-0000-4000-8000-000000000701', direction: 'general', format: 'structured' } ) ).resolves.toMatchObject({ entries: [ { comments: [ expect.objectContaining({ content: '先核对发布材料。' }) ] } ] }) expect(database.listTasks()).toEqual([]) expect(database.getTokenUsageSummary().records).toEqual([ expect.objectContaining({ requestId: analysisRequestId, provider: 'openai', model: 'test-model', totalTokens: 30 }) ]) expect(releaseConversation).toHaveBeenCalledWith( `magic-notes:${entry.id}` ) expect(disposeRuntime).toHaveBeenCalledOnce() } finally { await disposeHandlers() database.close() await rm(directory, { recursive: true, force: true }) } }) })