import { afterEach, describe, expect, it, vi } from 'vitest' import type {} from '@testing-library/jest-dom/vitest' import { EventEmitter } from 'node:events' 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 { ipcChannels } from '../shared/ipc-channels' import type { AssistantProject, AssistantSchedule, ConversationSnapshot, ConversationQueueItem } from '../shared/assistant-contracts' import { browserTabIdSchema, defaultRuntimeSettings, runtimeSettingsInputSchema, type AgentEvent, type BrowserLiveState, type BrowserTabId, type ConversationQueueDispatch } from '../shared/contracts' import type { SshHostAgentConnectionStatus } from '../shared/ssh-host-contracts' import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology' import { AssistantDatabase } from './assistant/assistant-database' import { 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 { KnowledgeService } from './knowledge/knowledge-service' import { KnowledgeDatabase } from './knowledge/knowledge-database' import { BrowserNavigationStoppedError } from './browser/browser-service' import { RemotePromptCancelledError } from './agent/acp-remote-runtime' import { CapabilityService } from './capabilities/capability-service' import { ObsidianService } from './obsidian' import { packageObsidianMcpVault } from './obsidian/package-mcpvault' import { registerIpcHandlers, sendRemoteEnvironmentUpdateProgress, sendRemoteProjectSaveProgress } from './ipc' describe('embedding IPC boundary', () => { it('validates explicit IDs and keeps ZIP paths in the native dialog', async () => { const source = await readFile( join(process.cwd(), 'src', 'main', 'ipc.ts'), 'utf8' ) const region = source.match( /const requireEmbeddingProvider[\s\S]*?registerHandler\(ipcChannels\.speechModelsGet/u )?.[0] ?? '' expect(region).toContain('embeddingConnectionIdRequestSchema.parse(input)') expect(region).toContain('embeddingModelInstallInputSchema.parse(input)') expect(region).toContain('embeddingModelActionInputSchema.parse(input)') expect(region).toContain( 'embeddingModelProgressSnapshotSchema.parse' ) expect(region).toContain( 'embeddingModelManager.getProgressSnapshot()' ) expect(region).toContain("properties: ['openFile']") expect(region).toContain('filters: modelArchiveDialogFilters') expect(region).not.toMatch( /embeddingModelsImportArchive[\s\S]{0,300}input\.(?:path|archivePath|directory)/u ) expect(region).not.toMatch( /(?:agent|selectedRuntimes|remoteModelGateway).{0,80}(?:embedding|vector)/iu ) }) }) describe('local tool environment IPC boundary', () => { it('trust-checks every operation and accepts only shared bounded schemas', async () => { const 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 = [ window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', {} as never, {} as never, { clear: vi.fn() } as never, {} as never, { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []) } as never, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, terminalSessionManager as never ] const dispose = registerIpcHandlers(...args) const event = { sender: webContents, senderFrame: webContents.mainFrame } await expect( electronMocks.handlers.get(ipcChannels.terminalCreate)?.(event, { target: { type: 'local' }, cols: 120, rows: 36 }) ).resolves.toEqual(snapshot) expect(terminalSessionManager.create).toHaveBeenCalledWith(73, { target: { type: 'local' }, cols: 120, rows: 36 }) await vi.waitFor(() => expect( terminalSessionManager.enableEventDelivery ).toHaveBeenCalledWith(73, snapshot.sessionId) ) electronMocks.handlers.get(ipcChannels.terminalWrite)?.(event, { sessionId: snapshot.sessionId, data: 'echo ready\r' }) electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, { sessionId: snapshot.sessionId, cols: 100, rows: 28 }) electronMocks.handlers.get(ipcChannels.terminalAck)?.(event, { sessionId: snapshot.sessionId, sequence: 4 }) expect( electronMocks.handlers.get(ipcChannels.terminalSnapshot)?.( event, { sessionId: snapshot.sessionId } ) ).toEqual(snapshot) await expect( electronMocks.handlers.get(ipcChannels.terminalClose)?.(event, { sessionId: snapshot.sessionId }) ).resolves.toMatchObject({ state: 'closed' }) expect(terminalSessionManager.write).toHaveBeenCalledWith( 73, snapshot.sessionId, 'echo ready\r' ) expect(terminalSessionManager.resize).toHaveBeenCalledWith( 73, snapshot.sessionId, { cols: 100, rows: 28 } ) expect(terminalSessionManager.acknowledge).toHaveBeenCalledWith( 73, snapshot.sessionId, 4 ) expect(terminalSessionManager.snapshot).toHaveBeenCalledWith( 73, snapshot.sessionId ) expect(terminalSessionManager.close).toHaveBeenCalledWith( 73, snapshot.sessionId ) expect( electronMocks.handlers.get(ipcChannels.clipboardReadText)?.(event) ).toBe('Get-Location\r') expect(electronMocks.readClipboardText).toHaveBeenCalledOnce() electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.( event, '## Copied response' ) expect(electronMocks.writeClipboardText).toHaveBeenCalledWith( '## Copied response' ) expect(() => electronMocks.handlers.get(ipcChannels.clipboardReadText)?.({ sender: { ...webContents, id: 99 }, senderFrame: webContents.mainFrame }) ).toThrow('拒绝来自未知窗口的 IPC 请求') expect(() => electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.( event, { text: 'invalid' } ) ).toThrow() expect(() => electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.( { sender: { ...webContents, id: 99 }, senderFrame: webContents.mainFrame }, 'untrusted' ) ).toThrow('拒绝来自未知窗口的 IPC 请求') expect(() => electronMocks.handlers.get(ipcChannels.terminalWrite)?.( { sender: { ...webContents, id: 99 }, senderFrame: webContents.mainFrame }, { sessionId: snapshot.sessionId, data: 'untrusted' } ) ).toThrow('拒绝来自未知窗口的 IPC 请求') expect(() => electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, { sessionId: snapshot.sessionId, cols: 0, rows: 28 }) ).toThrow() await dispose() expect(terminalSessionManager.closeOwner).toHaveBeenCalledWith(73) }) }) type InvokeHandler = (event: unknown, input?: unknown) => unknown type KnowledgeGrantMock = ( requestId: string, libraryIds: readonly string[], signal: AbortSignal, access: 'read' | 'write' ) => string const electronMocks = vi.hoisted(() => { const handlers = new Map() return { handlers, invoke: vi.fn(), handle: vi.fn((channel: string, handler: InvokeHandler) => { handlers.set(channel, handler) }), removeHandler: vi.fn((channel: string) => { handlers.delete(channel) }), showOpenDialog: vi.fn(async () => ({ canceled: true, filePaths: [] as string[] })), showSaveDialog: vi.fn(async () => ({ canceled: true, filePath: undefined as string | undefined })), openPath: vi.fn(async () => ''), showItemInFolder: vi.fn(), openExternal: vi.fn(async () => undefined), readClipboardText: vi.fn(() => 'Get-Location\r'), writeClipboardText: vi.fn() } }) const channelMocks = vi.hoisted(() => ({ executor: undefined as | (( message: { channel: string eventId: string senderId: string conversationId: string conversationType: 'direct' | 'group' text: string mentioned: boolean workMode: 'ask' attachments?: Array<{ name: string mimeType: string size: number kind: 'image' | 'file' dataBase64: string }> attachmentError?: string }, signal: AbortSignal, reportProgress?: (result: { status: string output?: string error?: string }) => Promise ) => 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, { clear: vi.fn() } as never, {} as never, onRuntimeSettingsChanged, undefined, { createTab, listTabs, getVisibleTabId, acquireTabUsage, closeTab, navigate, back, reload, snapshot: snapshotBrowser, click: clickBrowser, type: typeBrowser, select: selectBrowser, screenshot: screenshotBrowser, stopLoading, setViewport, releaseConversation, onState: (listener) => { browserStateListener = listener return vi.fn() } }, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, shortcutSettingsService as never ) const event = { sender: webContents, senderFrame: webContents.mainFrame } expect( electronMocks.handlers.get(ipcChannels.appInfo)?.(event) ).toMatchObject({ shortcut: 'Ctrl + Alt + K', shortcutStatus: 'registered' }) for (const status of ['disabled', 'conflict', 'failed'] as const) { shortcutSettingsService.getSnapshot.mockReturnValueOnce({ ...shortcutSnapshot, registered: false, registeredAccelerator: undefined, status }) expect(electronMocks.handlers.get(ipcChannels.appInfo)?.(event)).toMatchObject({ shortcut: '', shortcutStatus: status }) } expect( electronMocks.handlers.get(ipcChannels.shortcutSettingsGet)?.( event ) ).toEqual(shortcutSnapshot) await expect( electronMocks.handlers.get(ipcChannels.shortcutSettingsUpdate)?.( event, { enabled: true, accelerator: 'ctrl+alt+k' } ) ).resolves.toEqual({ ok: true, snapshot: shortcutSnapshot }) expect(shortcutSettingsService.update).toHaveBeenCalledWith({ enabled: true, accelerator: 'Control+Alt+K' }) await expect( electronMocks.handlers.get(ipcChannels.contextSelectFiles)?.(event) ).resolves.toEqual([]) expect(selectFiles).toHaveBeenCalledWith(window, expect.any(Function)) const importFiles = electronMocks.handlers.get(ipcChannels.contextImportFiles)! await expect(importFiles(event, { paths: ['C:\\scan.pdf'] })).resolves.toEqual([]) expect(selectFiles).toHaveBeenCalledWith(['C:\\scan.pdf'], expect.any(Function)) expect(() => importFiles(event, { paths: [''] })).toThrow() expect(() => importFiles({ ...event, senderFrame: {} }, { paths: ['C:\\scan.pdf'] })).toThrow() expect(webContents.send).toHaveBeenCalledWith( ipcChannels.contextFileSelectionProgress, { phase: 'parsing', fileName: 'scan.pdf', fileNumber: 1, fileCount: 1 } ) await expect( electronMocks.handlers.get( ipcChannels.capabilitiesToggleComputer )?.(event, { capabilityId: 'host-browser-control', enabled: true }) ).resolves.toEqual(snapshot) expect( capabilityService.setComputerCapabilityEnabled ).toHaveBeenCalledWith('host-browser-control', true) expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce() await expect( electronMocks.handlers.get( ipcChannels.capabilitiesToggleWebSearch )?.(event, false) ).resolves.toEqual(snapshot) expect(capabilityService.setWebSearchEnabled).toHaveBeenCalledWith(false) expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(2) await expect( electronMocks.handlers.get( ipcChannels.capabilitiesToggleBuiltinMcp )?.(event, { serverId: 'knowledge-base', enabled: false }) ).resolves.toEqual(snapshot) expect( capabilityService.setBuiltinMcpServerEnabled ).toHaveBeenCalledWith('knowledge-base', false) await expect( electronMocks.handlers.get( ipcChannels.capabilitiesAssignBuiltinMcp )?.(event, { serverId: 'knowledge-base', assignments: ['model', 'continue'] }) ).resolves.toEqual(snapshot) expect( capabilityService.setBuiltinMcpServerAssignments ).toHaveBeenCalledWith('knowledge-base', ['model', 'continue']) expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(4) electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: ['C:\\meeting-helper.zip'] }) await expect( electronMocks.handlers.get( ipcChannels.capabilitiesImportSkill )?.(event, 'zip') ).resolves.toEqual(snapshot) expect(electronMocks.showOpenDialog).toHaveBeenCalledWith( window, expect.objectContaining({ properties: ['openFile'], filters: [{ name: 'Skill ZIP', extensions: ['zip'] }] }) ) expect(capabilityService.importSkill).toHaveBeenCalledWith( 'C:\\meeting-helper.zip' ) await expect( electronMocks.handlers.get( ipcChannels.capabilitiesImportSkill )?.(event, 'unsupported') ).rejects.toThrow() const browserTabId = browserTabIdSchema.parse( '4d7886c3-4c9c-4e84-bf65-1a4a841f5296' ) const siblingBrowserTabId = browserTabIdSchema.parse( '0387bd61-3a12-40ce-98d7-ef5d14cc8251' ) browserStateListener?.({ conversationId: 'browser-conversation', tabId: browserTabId, ownerWindowId: 7, status: 'ready', sessionActive: true, isLoading: false, canGoBack: false, workbarInstanceId: browserTabId, updatedAt: 1 }) expect(webContents.send).toHaveBeenCalledWith( ipcChannels.browserState, expect.objectContaining({ conversationId: 'browser-conversation', status: 'ready' }) ) const ownedStateSendCount = webContents.send.mock.calls.length browserStateListener?.({ conversationId: 'foreign-conversation', tabId: browserTabId, ownerWindowId: 8, status: 'ready', sessionActive: true, isLoading: false, canGoBack: false, updatedAt: 1 }) expect(webContents.send).toHaveBeenCalledTimes(ownedStateSendCount) browserStateListener?.({ conversationId: 'browser-conversation', tabId: browserTabId, ownerWindowId: 7, status: 'acting', sessionActive: true, isLoading: false, canGoBack: false, workbarInstanceId: browserTabId, updatedAt: 2 }) const repeatedFramePayload = webContents.send.mock.calls.at(-1)?.[1] expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl') expect(repeatedFramePayload).toHaveProperty('workbarInstanceId', browserTabId) browserStateListener?.({ conversationId: 'browser-conversation', tabId: siblingBrowserTabId, ownerWindowId: 7, status: 'ready', sessionActive: true, isLoading: false, canGoBack: false, workbarInstanceId: siblingBrowserTabId, updatedAt: 2 }) expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty( 'workbarInstanceId', siblingBrowserTabId ) browserStateListener?.({ conversationId: 'browser-conversation', tabId: browserTabId, ownerWindowId: 7, status: 'stopped', sessionActive: false, isLoading: false, canGoBack: false, updatedAt: 3 }) browserStateListener?.({ conversationId: 'browser-conversation', tabId: browserTabId, ownerWindowId: 7, status: 'ready', sessionActive: true, isLoading: false, canGoBack: false, workbarInstanceId: browserTabId, updatedAt: 4 }) expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty( 'workbarInstanceId', browserTabId ) await expect( electronMocks.handlers.get(ipcChannels.browserStop)?.(event, { conversationId: 'browser-conversation' }) ).resolves.toBeUndefined() expect(releaseConversation).toHaveBeenCalledWith( 'browser-conversation', 7 ) await expect( electronMocks.handlers.get(ipcChannels.browserCreateTab)?.(event, { conversationId: 'browser-conversation', workbarInstanceId: siblingBrowserTabId }) ).resolves.toMatchObject({ tabId: browserTabId }) expect(createTab).toHaveBeenCalledWith( 'browser-conversation', 7, expect.any(AbortSignal), siblingBrowserTabId ) await expect( electronMocks.handlers.get(ipcChannels.browserListTabs)?.(event, { conversationId: 'browser-conversation' }) ).resolves.toEqual([]) expect(listTabs).toHaveBeenCalledWith('browser-conversation', 7) await expect( electronMocks.handlers.get(ipcChannels.browserCloseTab)?.(event, { conversationId: 'browser-conversation', tabId: browserTabId }) ).resolves.toBeUndefined() expect(closeTab).toHaveBeenCalledWith( 'browser-conversation', browserTabId, 7 ) await expect( electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, { conversationId: 'browser-conversation', url: 'https://example.com/' }) ).resolves.toBeUndefined() expect(navigate).toHaveBeenCalledWith( 'browser-conversation', 'https://example.com/', expect.any(AbortSignal), undefined, 7 ) await expect( electronMocks.handlers.get(ipcChannels.browserBack)?.(event, { conversationId: 'browser-conversation' }) ).resolves.toBeUndefined() expect(back).toHaveBeenCalledWith( 'browser-conversation', expect.any(AbortSignal), undefined, 7 ) await expect( electronMocks.handlers.get(ipcChannels.browserReload)?.(event, { conversationId: 'browser-conversation' }) ).resolves.toBeUndefined() expect(reload).toHaveBeenCalledWith( 'browser-conversation', expect.any(AbortSignal), undefined, 7 ) await expect( electronMocks.handlers.get(ipcChannels.browserStopLoading)?.( event, { conversationId: 'browser-conversation' } ) ).resolves.toBeUndefined() expect(stopLoading).toHaveBeenCalledWith( 'browser-conversation', undefined, 7 ) expect( electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, { conversationId: 'browser-conversation', leaseToken: siblingBrowserTabId, bounds: { x: 900, y: 120, width: 320, height: 600 } }) ).toBeUndefined() expect(setViewport).toHaveBeenCalledWith( 'browser-conversation', { x: 900, y: 120, width: 320, height: 600 }, undefined, siblingBrowserTabId, 7 ) expect( electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, { leaseToken: siblingBrowserTabId }) ).toBeUndefined() expect(setViewport).toHaveBeenLastCalledWith( undefined, undefined, undefined, siblingBrowserTabId, 7 ) navigate.mockImplementationOnce( async (...args: unknown[]) => new Promise((_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 await vi.waitFor(() => expect(webContents.listenerCount('destroyed')).toBe(1)) webContents.emit('destroyed') await expect(abandonedNavigation).rejects.toThrow('所属窗口已关闭') navigate.mockRejectedValueOnce(new BrowserNavigationStoppedError()) await expect( electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, { conversationId: 'browser-conversation', url: 'https://example.com/slow' }) ).resolves.toBeUndefined() capabilityService.getComputerCapabilityStatus.mockResolvedValueOnce({ enabled: false, supported: true }) await expect( electronMocks.handlers.get(ipcChannels.browserBack)?.(event, { conversationId: 'browser-conversation' }) ).resolves.toBeUndefined() expect(back).toHaveBeenCalledTimes(2) capabilityService.getComputerCapabilityStatus.mockResolvedValueOnce({ enabled: true, supported: false }) await expect( electronMocks.handlers.get(ipcChannels.browserReload)?.(event, { conversationId: 'browser-conversation' }) ).rejects.toThrow('不支持') await expect( electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, { conversationId: 'browser-conversation', url: 'https://example.com/', debugger: true }) ).rejects.toThrow() await expect( electronMocks.handlers.get(ipcChannels.browserStopLoading)?.( { sender: {}, senderFrame: webContents.mainFrame }, { conversationId: 'browser-conversation' } ) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') await expect( electronMocks.handlers.get(ipcChannels.browserStopLoading)?.( event, { conversationId: 'browser-conversation', rawElectron: true } ) ).rejects.toThrow() expect(() => electronMocks.handlers.get( ipcChannels.capabilitiesCreateBrowserProfile )?.(event, { name: '工作配置', executable: 'C:\\unsafe.exe' }) ).toThrow() expect(capabilityService.createBrowserProfile).not.toHaveBeenCalled() await expect( electronMocks.handlers.get( ipcChannels.capabilitiesCreateBrowserProfile )?.(event, { name: '工作配置' }) ).resolves.toEqual(snapshot) expect(capabilityService.createBrowserProfile).toHaveBeenCalledWith( '工作配置' ) expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(5) 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 ]) expect(() => electronMocks.handlers.get( ipcChannels.capabilitiesDiagnoseComputer )?.( { sender: {}, senderFrame: webContents.mainFrame }, 'host-browser-control' ) ).toThrow('拒绝来自未知窗口的 IPC 请求') await dispose() for (const channel of capabilityChannels) { expect(electronMocks.removeHandler).toHaveBeenCalledWith(channel) expect(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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => undefined), undefined, undefined, undefined, undefined, { get: getApplicationSettings, onChanged } as never, versionChecker as never ) const changedSettings = { updateSource: 'github' } onChanged.mock.calls[0]![0](changedSettings) expect(webContents.send).toHaveBeenCalledWith(ipcChannels.applicationSettingsChanged, changedSettings) await expect( electronMocks.handlers.get(ipcChannels.versionCheck)?.(event) ).resolves.toEqual(result) expect(versionChecker.check).toHaveBeenCalledWith('mirror') expect(webContents.send).toHaveBeenCalledWith( ipcChannels.versionCheckResult, result ) await expect( electronMocks.handlers.get( ipcChannels.versionOpenReleasePage )?.(event) ).resolves.toBeUndefined() expect(electronMocks.openExternal).toHaveBeenLastCalledWith( 'https://mesalogo.github.io/goodbuddy/#download' ) getApplicationSettings.mockResolvedValueOnce({ updateSource: 'github' }) await expect( electronMocks.handlers.get( ipcChannels.versionOpenReleasePage )?.(event) ).resolves.toBeUndefined() expect(electronMocks.openExternal).toHaveBeenLastCalledWith( 'https://github.com/mesalogo/goodbuddy/releases' ) await dispose() expect(unsubscribe).toHaveBeenCalledOnce() }) }) describe('registerIpcHandlers model download source routing', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('uses the persisted source and rejects stale renderer requests', async () => { const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => undefined), undefined, undefined, undefined, undefined, applicationSettingsStore as never, undefined, speechModelManager as never, undefined, undefined, undefined, undefined, undefined, documentParsingService as never, documentOcrModelManager as never ) await expect( electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.( event, { modelId: 'speech-model', expectedDownloadSource: 'hugging-face' } ) ).resolves.toEqual(speechSnapshot) expect(speechModelManager.install).toHaveBeenCalledWith( 'speech-model', 'hugging-face' ) await expect( electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.( event, { modelId: 'speech-model', expectedDownloadSource: 'modelscope' } ) ).rejects.toThrow('模型下载源已变化') expect(speechModelManager.install).toHaveBeenCalledTimes(1) applicationSettingsStore.get.mockRejectedValueOnce( new Error('settings unavailable') ) await expect( electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.( event, { modelId: 'speech-model', expectedDownloadSource: 'hugging-face' } ) ).rejects.toThrow('settings unavailable') expect(speechModelManager.install).toHaveBeenCalledTimes(1) await expect( electronMocks.handlers.get( ipcChannels.documentOcrModelsInstall )?.(event, { modelId: 'ocr-model', expectedDownloadSource: 'hugging-face' }) ).resolves.toEqual(ocrSnapshot) expect(documentOcrModelManager.install).toHaveBeenCalledWith( 'ocr-model', 'hugging-face' ) expect( electronMocks.handlers.get( ipcChannels.documentOcrModelsProgress )?.(event) ).toEqual({ operations: [] }) expect( documentOcrModelManager.getProgressSnapshot ).toHaveBeenCalledOnce() expect(() => electronMocks.handlers .get(ipcChannels.documentOcrModelsProgress) ?.({ sender: {}, senderFrame: webContents.mainFrame }) ).toThrow('拒绝来自未知窗口的 IPC 请求') await expect( electronMocks.handlers.get( ipcChannels.speechModelsOpenRepository )?.(event, { modelId: 'speech-model' }) ).resolves.toBeUndefined() expect(speechModelManager.getRepositoryUrl).toHaveBeenCalledWith( 'speech-model', 'hugging-face' ) expect(electronMocks.openExternal).toHaveBeenLastCalledWith( 'https://huggingface.co/example/speech' ) await expect( electronMocks.handlers.get( ipcChannels.documentOcrModelsOpenRepository )?.(event, { modelId: 'ocr-model' }) ).resolves.toBeUndefined() expect( documentOcrModelManager.getRepositoryUrl ).toHaveBeenCalledWith('ocr-model', 'hugging-face') expect(electronMocks.openExternal).toHaveBeenLastCalledWith( 'https://huggingface.co/example/ocr' ) await dispose() }) }) vi.mock('electron', () => ({ 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, { clear: vi.fn() } as never, {} as never, onRuntimeSettingsChanged, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, runtimeExtensionStore as never ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const action = { type: 'set-enabled', extensionId: 'dsh-plugin-greet', enabled: true } await expect( electronMocks.handlers.get( ipcChannels.runtimeExtensionsSnapshot )?.(event) ).resolves.toEqual(snapshot) await expect( electronMocks.handlers.get( ipcChannels.runtimeExtensionsApply )?.(event, action) ).resolves.toEqual(snapshot) expect( runtimeExtensionStore.applyWithResult ).toHaveBeenCalledWith(action) expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce() const marketplaceAction = { type: 'set-marketplace-enabled', enabled: false } runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({ snapshot: { ...snapshot, marketplaceEnabled: false }, changed: true }) await expect( electronMocks.handlers.get( ipcChannels.runtimeExtensionsApply )?.(event, marketplaceAction) ).resolves.toEqual({ ...snapshot, marketplaceEnabled: false }) expect( runtimeExtensionStore.applyWithResult ).toHaveBeenLastCalledWith(marketplaceAction) expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce() runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({ snapshot, changed: false }) await expect( electronMocks.handlers.get( ipcChannels.runtimeExtensionsApply )?.(event, action) ).resolves.toEqual(snapshot) expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce() await expect( electronMocks.handlers.get( ipcChannels.runtimeExtensionsApply )?.(event, { ...action, extensionId: 'Invalid Extension' }) ).rejects.toThrow() expect(() => electronMocks.handlers.get( ipcChannels.runtimeExtensionsSnapshot )?.({ sender: {}, senderFrame: webContents.mainFrame }) ).toThrow('拒绝来自未知窗口的 IPC 请求') await dispose() }) }) describe('registerIpcHandlers SSH hosts', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('validates narrow host requests and restricts them to the trusted renderer', async () => { const webContents = { 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 >(async () => directoryListing) const sshHostDirectoryBrowser = { listDirectories } const remoteEnvironment = { hostId, checkedAt: '2030-01-01T00:00:00.000Z', architecture: 'x64', agent: { state: 'current', expected: { version: '0.11.1', architecture: 'x64' }, installed: { version: '0.11.1', architecture: 'x64' } }, runtimes: [{ runtimeId: 'opencode', provider: 'opencode', state: 'current', expected: { version: '1.18.29', architecture: 'x64' }, installed: { version: '1.18.29', architecture: 'x64' } }], remoteDownload: { available: true, source: 'mirror', packageSize: 1024 } } as const const inspectRemoteEnvironment = vi.fn( async () => remoteEnvironment ) const sshHostRemoteEnvironmentInspector = { inspect: inspectRemoteEnvironment } const remoteEnvironmentUpdateService = { update: vi.fn( async ( owner: typeof webContents, request: { hostId: string method: 'remote-download' | 'goodbuddy-transfer' }, onProgress?: (progress: { hostId: string method: 'remote-download' | 'goodbuddy-transfer' phase: 'verifying' }) => void ) => { expect(owner).toBe(webContents) onProgress?.({ hostId: request.hostId, method: request.method, phase: 'verifying' }) } ), cancel: vi.fn(), dispose: vi.fn(async () => undefined) } const agentPackageInventory = { checkedAt: '2030-01-01T00:00:00.000Z', catalog: { state: 'available', checkedAt: '2030-01-01T00:00:00.000Z', error: null }, entries: [ { platform: 'linux', architecture: 'x64', state: 'verified', version: '0.11.2', latestVersion: '0.11.4', updateAvailable: true, remoteRuntimeVersion: '1.18.29', agentProtocol: { major: 2, minor: 0 } }, { platform: 'linux', architecture: 'arm64', state: 'not-downloaded', version: null, latestVersion: '0.11.4', updateAvailable: false, remoteRuntimeVersion: null, agentProtocol: null } ] } as const const agentPackageManager = { getAllSnapshot: vi.fn(async () => agentPackageInventory), forPlatform: vi.fn(() => agentPackageManager), download: vi.fn( async ( architecture: 'x64' | 'arm64', onProgress?: (progress: { architecture: 'x64' | 'arm64' phase: 'downloading' completedBytes: number totalBytes: number }) => void ) => { onProgress?.({ architecture, phase: 'downloading', completedBytes: 1, totalBytes: 2 }) return agentPackageInventory } ), importArchive: vi.fn(async () => agentPackageInventory), getExportArchiveName: vi.fn( async () => 'goodbuddy-agent-0.11.2-linux-arm64.gbagent' ), exportArchive: vi.fn(async () => undefined) } const selectedRuntimes = { reset: vi.fn(async () => undefined) } let publishAgentConnectionStatus: | ((status: SshHostAgentConnectionStatus) => void) | undefined const removeAgentConnectionStatusListener = vi.fn() const remoteAgentConnectionManager = { getHostConnectionState: vi.fn(() => 'ready' as const), onHostConnectionStateChange: vi.fn( ( listener: (status: SshHostAgentConnectionStatus) => void ) => { publishAgentConnectionStatus = listener return removeAgentConnectionStatusListener } ) } const dispose = registerIpcHandlers( window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', {} as never, {} as never, { clear: vi.fn() } as never, {} as never, { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []), listSshHostProjectReferences, deleteProjectsReferencingSshHost } as never, { clear: vi.fn() } as never, {} as never, vi.fn(async () => undefined), undefined, // onBeforeClearLocalData undefined, // browserControl undefined, // subagentService undefined, // channelSettingsStore { get: vi.fn(async () => ({ remoteProjectsEnabled: true })), onChanged: vi.fn() } as never, // applicationSettingsStore undefined, // versionChecker undefined, // speechModelManager undefined, // embeddingIndexCoordinator selectedRuntimes as never, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, sshHostService as never, undefined, remoteProjectSaveService as never, sshHostDirectoryBrowser as never, sshHostRemoteEnvironmentInspector as never, undefined, undefined, undefined, remoteEnvironmentUpdateService as never, agentPackageManager as never, remoteAgentConnectionManager as never ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const createInput = { name: ' Build host ', hostname: 'build.example.com', port: 22, username: 'builder', authentication: 'system-agent', password: { action: 'clear' } } await expect( electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event) ).resolves.toEqual({ ...snapshot, projectReferences: {}, agentConnectionStatusByHostId: { [hostId]: 'ready' } }) expect( remoteAgentConnectionManager.getHostConnectionState ).toHaveBeenCalledWith(hostId) publishAgentConnectionStatus?.({ hostId, state: 'connecting' }) expect(webContents.send).toHaveBeenCalledWith( ipcChannels.sshHostsAgentConnectionStatus, { hostId, state: 'connecting' } ) await expect( electronMocks.handlers.get( ipcChannels.sshHostsAgentPackageInventory )?.(event, { refresh: true }) ).resolves.toEqual(agentPackageInventory) expect( agentPackageManager.getAllSnapshot ).toHaveBeenCalledWith({ refresh: true }) await expect( electronMocks.handlers.get( ipcChannels.sshHostsAgentPackageDownload )?.(event, { architecture: 'x64' }) ).resolves.toEqual(agentPackageInventory) expect(agentPackageManager.download).toHaveBeenCalledWith( 'x64', expect.any(Function) ) expect(webContents.send).toHaveBeenCalledWith( ipcChannels.sshHostsAgentPackageProgress, { architecture: 'x64', platform: 'linux', phase: 'downloading', completedBytes: 1, totalBytes: 2 } ) electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: ['C:\\transfer\\agent.gbagent'] }) await expect( electronMocks.handlers.get( ipcChannels.sshHostsAgentPackageImport )?.(event) ).resolves.toEqual(agentPackageInventory) expect(agentPackageManager.importArchive).toHaveBeenCalledWith( 'C:\\transfer\\agent.gbagent' ) electronMocks.showSaveDialog.mockResolvedValueOnce({ canceled: false, filePath: 'C:\\transfer\\agent' }) await expect( electronMocks.handlers.get( ipcChannels.sshHostsAgentPackageExport )?.(event, { architecture: 'arm64' }) ).resolves.toBeUndefined() expect(agentPackageManager.exportArchive).toHaveBeenCalledWith( 'arm64', 'C:\\transfer\\agent.gbagent' ) expect( agentPackageManager.getExportArchiveName ).toHaveBeenCalledWith('arm64') await expect( electronMocks.handlers.get( ipcChannels.sshHostsInspectDraftKey )?.( event, { hostId, hostname: createInput.hostname, port: createInput.port, username: createInput.username } ) ).resolves.toMatchObject({ fingerprintSha256: fingerprint }) expect( sshHostService.inspectDraftHostKey ).toHaveBeenCalledWith({ hostId, hostname: createInput.hostname, port: createInput.port, username: createInput.username }) await expect( electronMocks.handlers.get( ipcChannels.sshHostsValidateAndSave )?.( event, { candidateId, fingerprintSha256: fingerprint, input: createInput } ) ).resolves.toMatchObject({ host, connection: { connected: true } }) expect(sshHostService.validateAndSave).toHaveBeenCalledWith({ candidateId, fingerprintSha256: fingerprint, input: { ...createInput, name: 'Build host' } }) await expect( electronMocks.handlers.get( ipcChannels.sshHostsDiscardCandidate )?.(event, { candidateId }) ).resolves.toBeUndefined() expect(sshHostService.discardCandidate).toHaveBeenCalledWith( candidateId ) await expect( electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.( event, { hostId } ) ).resolves.toEqual({ hostId, deletedProjects: [] }) deleteProjectsReferencingSshHost.mockReturnValue([ { id: '00000000-0000-4000-8000-000000000107', name: 'Remote project' } ]) await expect( electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.( event, { hostId } ) ).resolves.toEqual({ hostId, deletedProjects: [ { id: '00000000-0000-4000-8000-000000000107', name: 'Remote project' } ] }) expect(sshHostService.remove).toHaveBeenCalledTimes(2) sshHostService.remove.mockRejectedValueOnce( new Error('Host store unavailable') ) await expect( electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.( event, { hostId } ) ).rejects.toThrow('Host store unavailable') expect(deleteProjectsReferencingSshHost).toHaveBeenCalledTimes(2) await expect( electronMocks.handlers.get( ipcChannels.sshHostsRemoteEnvironment )?.(event, { hostId }) ).resolves.toEqual(remoteEnvironment) expect(inspectRemoteEnvironment).toHaveBeenCalledWith(hostId) await expect( electronMocks.handlers.get( ipcChannels.sshHostsRemoteEnvironment )?.(event, { hostId: 'not-a-uuid' }) ).rejects.toThrow() expect(inspectRemoteEnvironment).toHaveBeenCalledOnce() await expect( electronMocks.handlers.get( ipcChannels.sshHostsUpdateRemoteEnvironment )?.(event, { hostId, method: 'remote-download' }) ).resolves.toBeUndefined() expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledWith( webContents, { hostId, method: 'remote-download' }, expect.any(Function) ) expect(webContents.send).toHaveBeenCalledWith( ipcChannels.sshHostsRemoteEnvironmentUpdateProgress, { hostId, method: 'remote-download', phase: 'verifying' } ) await expect( electronMocks.handlers.get( ipcChannels.sshHostsUpdateRemoteEnvironment )?.(event, { hostId, method: 'remote-download', command: 'whoami' }) ).rejects.toThrow() await expect( electronMocks.handlers.get( ipcChannels.sshHostsUpdateRemoteEnvironment )?.( { sender: {}, senderFrame: webContents.mainFrame }, { hostId, method: 'remote-download' } ) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledOnce() await expect( electronMocks.handlers.get( ipcChannels.sshHostsCancelRemoteEnvironmentUpdate )?.(event, { hostId }) ).resolves.toBeUndefined() expect(remoteEnvironmentUpdateService.cancel).toHaveBeenCalledWith( webContents, hostId ) await expect( electronMocks.handlers.get( ipcChannels.sshHostsValidateAndSave )?.(event, { candidateId: 'not-a-uuid', fingerprintSha256: fingerprint, input: { ...createInput, command: 'whoami' } }) ).rejects.toThrow() expect(sshHostService.validateAndSave).toHaveBeenCalledOnce() await expect( electronMocks.handlers.get(ipcChannels.sshHostsGet)?.({ sender: {}, senderFrame: webContents.mainFrame }) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') await expect( electronMocks.handlers.get( ipcChannels.sshHostsAgentPackageInventory )?.({ sender: {}, senderFrame: webContents.mainFrame }) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') expect( agentPackageManager.getAllSnapshot ).toHaveBeenCalledTimes(3) await expect( electronMocks.handlers.get( ipcChannels.sshHostsBrowseDirectories )?.(event, { hostId, path: '/srv' }) ).resolves.toEqual(directoryListing) expect( sshHostDirectoryBrowser.listDirectories ).toHaveBeenCalledWith( hostId, '/srv', expect.any(AbortSignal) ) await expect( electronMocks.handlers.get( ipcChannels.sshHostsBrowseDirectories )?.(event, { hostId, path: '../escape' }) ).rejects.toThrow() expect( sshHostDirectoryBrowser.listDirectories ).toHaveBeenCalledOnce() await expect( electronMocks.handlers.get( ipcChannels.sshHostsBrowseDirectories )?.( { sender: {}, senderFrame: webContents.mainFrame }, { hostId } ) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') let browseSignal: AbortSignal | undefined sshHostDirectoryBrowser.listDirectories.mockImplementationOnce( async (...args): Promise => { const signal = args[2] if (!signal) { throw new Error('Expected directory browse AbortSignal') } return new Promise((_resolve, reject) => { browseSignal = signal signal.addEventListener( 'abort', () => reject(signal.reason), { once: true } ) }) } ) const activeBrowse = electronMocks.handlers.get( ipcChannels.sshHostsBrowseDirectories )?.(event, { hostId }) await vi.waitFor(() => expect(browseSignal).toBeInstanceOf(AbortSignal) ) await expect( electronMocks.handlers.get( ipcChannels.sshHostsCancelDirectoryBrowse )?.(event) ).resolves.toBeUndefined() await expect(activeBrowse).rejects.toMatchObject({ name: 'AbortError' }) const remoteDraft = { intent: 'create', draft: { name: 'Remote project', description: '', runtimeSelection: { provider: 'opencode' }, hostId, remoteRootPath: '/srv/project', defaultWorkMode: 'ask' } } await expect( electronMocks.handlers.get( ipcChannels.remoteProjectSave )?.(event, remoteDraft) ).resolves.toEqual(remoteProject) expect(remoteProjectSaveService.save).toHaveBeenCalledWith( webContents, remoteDraft ) await expect( electronMocks.handlers.get( ipcChannels.remoteProjectCancelCurrent )?.(event) ).resolves.toBeUndefined() expect( remoteProjectSaveService.cancelCurrent ).toHaveBeenCalledWith(webContents) expect(selectedRuntimes.reset).not.toHaveBeenCalled() expect(selectedRuntimes.reset).not.toHaveBeenCalled() await expect( electronMocks.handlers.get( ipcChannels.remoteProjectSave )?.( { sender: {}, senderFrame: webContents.mainFrame }, remoteDraft ) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') await dispose() expect(removeAgentConnectionStatusListener).toHaveBeenCalledOnce() expect(remoteProjectSaveService.dispose).toHaveBeenCalledOnce() expect( remoteEnvironmentUpdateService.dispose ).toHaveBeenCalledOnce() }) it('blocks disabled and unavailable remote-project access before remote services', async () => { const hostId = '00000000-0000-4000-8000-000000000105' const candidateId = '00000000-0000-4000-8000-000000000106' const projectId = '00000000-0000-4000-8000-000000000107' const fingerprint = `SHA256:${'A'.repeat(43)}` const remoteProject = { id: projectId, name: 'Remote project', description: '', rootPath: '/srv/project', executionSpace: { kind: 'ssh' as const, hostId, remoteRootPath: '/srv/project' }, defaultWorkMode: 'ask' as const, kind: 'user' as const, status: 'active' as const, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z' } const applicationSettingsStore = { onChanged: vi.fn(), get: vi.fn(async () => ({ remoteProjectsEnabled: false })) } const sshHostService = { getSnapshot: vi.fn(), remove: vi.fn(), inspectDraftHostKey: vi.fn(), discardCandidate: vi.fn(), validateAndSave: vi.fn() } const remoteProjectSaveService = { activate: vi.fn(), save: vi.fn(), cancelCurrent: vi.fn(), dispose: vi.fn(async () => undefined) } const sshHostDirectoryBrowser = { listDirectories: vi.fn() } const sshHostRemoteEnvironmentInspector = { inspect: vi.fn() } const remoteEnvironmentUpdateService = { update: vi.fn(), cancel: vi.fn(), dispose: vi.fn(async () => undefined) } const executionSpaceResolver = { resolveProject: vi.fn() } const assistantDatabase = { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []), listSshHostProjectReferences: vi.fn(() => []), deleteProjectsReferencingSshHost: vi.fn(() => []), getProject: vi.fn(() => remoteProject), updateProject: vi.fn(), setProjectArchived: vi.fn(), deleteProject: vi.fn() } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => undefined), undefined, undefined, undefined, undefined, applicationSettingsStore as never, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, sshHostService as never, executionSpaceResolver as never, remoteProjectSaveService as never, sshHostDirectoryBrowser as never, sshHostRemoteEnvironmentInspector as never, undefined, undefined, undefined, remoteEnvironmentUpdateService as never ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const createInput = { name: 'Build host', hostname: 'build.example.com', port: 22, username: 'builder', authentication: 'system-agent', password: { action: 'clear' } } const remoteDraft = { intent: 'create', draft: { name: remoteProject.name, description: '', runtimeSelection: { provider: 'opencode' }, hostId, remoteRootPath: '/srv/project', defaultWorkMode: 'ask' } } const disabledRequests: Array<[string, unknown?]> = [ [ipcChannels.sshHostsGet], [ipcChannels.sshHostsRemove, { hostId }], [ ipcChannels.sshHostsInspectDraftKey, { hostId, hostname: createInput.hostname, port: createInput.port, username: createInput.username } ], [ipcChannels.sshHostsDiscardCandidate, { candidateId }], [ ipcChannels.sshHostsValidateAndSave, { candidateId, fingerprintSha256: fingerprint, input: createInput } ], [ipcChannels.sshHostsRemoteEnvironment, { hostId }], [ ipcChannels.sshHostsUpdateRemoteEnvironment, { hostId, method: 'remote-download' } ], [ ipcChannels.sshHostsBrowseDirectories, { hostId, path: '/srv' } ], [ipcChannels.remoteProjectSave, remoteDraft] ] for (const [channel, input] of disabledRequests) { await expect( electronMocks.handlers.get(channel)?.(event, input) ).rejects.toThrow('远程项目未启用') } await expect( electronMocks.handlers.get(ipcChannels.projectsUpdate)?.(event, { projectId, input: { name: remoteProject.name, description: '', rootPath: remoteProject.rootPath, defaultWorkMode: 'ask' } }) ).rejects.toThrow('远程项目未启用') await expect( electronMocks.handlers.get(ipcChannels.projectsSetArchived)?.( event, { projectId, archived: true } ) ).rejects.toThrow('远程项目未启用') await expect( electronMocks.handlers.get(ipcChannels.projectsDelete)?.(event, { projectId, confirmation: remoteProject.name }) ).rejects.toThrow('远程项目未启用') await expect( electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.( event, { projectId, path: 'README.md' } ) ).rejects.toThrow('远程项目未启用') expect(sshHostService.getSnapshot).not.toHaveBeenCalled() expect(sshHostService.remove).not.toHaveBeenCalled() expect(sshHostService.inspectDraftHostKey).not.toHaveBeenCalled() expect(sshHostService.discardCandidate).not.toHaveBeenCalled() expect(sshHostService.validateAndSave).not.toHaveBeenCalled() expect( sshHostRemoteEnvironmentInspector.inspect ).not.toHaveBeenCalled() expect( sshHostDirectoryBrowser.listDirectories ).not.toHaveBeenCalled() expect(remoteProjectSaveService.activate).not.toHaveBeenCalled() expect(remoteProjectSaveService.save).not.toHaveBeenCalled() expect(remoteEnvironmentUpdateService.update).not.toHaveBeenCalled() await expect( electronMocks.handlers.get( ipcChannels.sshHostsCancelDirectoryBrowse )?.(event) ).resolves.toBeUndefined() await expect( electronMocks.handlers.get( ipcChannels.sshHostsCancelRemoteEnvironmentUpdate )?.(event, { hostId }) ).resolves.toBeUndefined() expect(remoteEnvironmentUpdateService.cancel).toHaveBeenCalledWith( webContents, hostId ) await expect( electronMocks.handlers.get( ipcChannels.remoteProjectCancelCurrent )?.(event) ).resolves.toBeUndefined() expect(remoteProjectSaveService.cancelCurrent).toHaveBeenCalledWith( webContents ) expect( assistantDatabase.listSshHostProjectReferences ).not.toHaveBeenCalled() expect( assistantDatabase.deleteProjectsReferencingSshHost ).not.toHaveBeenCalled() expect(assistantDatabase.updateProject).not.toHaveBeenCalled() expect(assistantDatabase.setProjectArchived).not.toHaveBeenCalled() expect(assistantDatabase.deleteProject).not.toHaveBeenCalled() expect(executionSpaceResolver.resolveProject).not.toHaveBeenCalled() applicationSettingsStore.get.mockRejectedValueOnce( new Error('settings unavailable') ) await expect( electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event) ).rejects.toThrow('远程项目未启用') expect(sshHostService.getSnapshot).not.toHaveBeenCalled() await dispose() }) it('sends save progress only to a live owning WebContents', () => { const owner = { isDestroyed: vi.fn(() => false), send: vi.fn(), once: vi.fn(), removeListener: vi.fn() } const progress = { phase: 'agent' as const } sendRemoteProjectSaveProgress(owner as never, progress) expect(owner.send).toHaveBeenCalledWith( ipcChannels.remoteProjectSaveProgress, progress ) owner.isDestroyed.mockReturnValue(true) sendRemoteProjectSaveProgress(owner as never, progress) expect(owner.send).toHaveBeenCalledOnce() }) it('sends validated update progress only to a live owning WebContents', () => { const owner = { id: 1, isDestroyed: vi.fn(() => false), send: vi.fn(), on: vi.fn(), removeListener: vi.fn() } const progress = { hostId: '00000000-0000-4000-8000-000000000105', method: 'remote-download' as const, phase: 'installing-runtime' as const } sendRemoteEnvironmentUpdateProgress(owner as never, progress) expect(owner.send).toHaveBeenCalledWith( ipcChannels.sshHostsRemoteEnvironmentUpdateProgress, progress ) expect(() => sendRemoteEnvironmentUpdateProgress(owner as never, { ...progress, phase: 'uploading' } as never) ).toThrow() owner.isDestroyed.mockReturnValue(true) sendRemoteEnvironmentUpdateProgress(owner as never, progress) expect(owner.send).toHaveBeenCalledOnce() }) }) function runtimeUpdateFixture( workspacePath: string, provider: 'model' | 'opencode' | 'continue' = 'model' ) { const profileId = '00000000-0000-4000-8000-000000000095' const profile = { id: profileId, name: 'Default', baseUrl: 'https://model.example/v1', modelName: 'model', protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const, apiKeyConfigured: true, credentialSource: 'encrypted' as const } const publicSettings = { provider, modelBaseUrl: profile.baseUrl, modelName: profile.modelName, modelProtocol: profile.protocol, modelAuthentication: profile.authentication, imageGenerationQuality: profile.imageGenerationQuality, opencodeBaseUrl: '', opencodeEmbedded: true, opencodeBinaryPath: '', opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '', continueMode: 'chat' as const, subagentSmartRoutingEnabled: false, knowledgeEmbeddingEnabled: false, knowledgeEmbeddingBaseUrl: 'https://embedding.example/v1', knowledgeEmbeddingModel: 'embedding', knowledgeEmbeddingApiKeyConfigured: false, knowledgeEmbeddingCredentialSource: 'none' as const, knowledgeRerankEnabled: false, knowledgeRerankEndpoint: 'https://rerank.example/v1', knowledgeRerankModel: 'rerank', knowledgeRerankApiKeyConfigured: false, knowledgeRerankCredentialSource: 'none' as const, workspacePath, apiKeyConfigured: true, credentialSource: 'encrypted' as const, modelProfiles: [profile], defaultModelProfileId: profileId, opencodeModelSource: { kind: 'platform' as const }, continueModelSource: { kind: 'platform' as const }, deepseekHarnessModelSource: { kind: 'platform' as const }, secureStorageAvailable: true, toolApproval: 'always' as const } return { publicSettings, input: { provider, modelBaseUrl: profile.baseUrl, modelName: profile.modelName, modelProtocol: profile.protocol, modelAuthentication: profile.authentication, imageGenerationQuality: profile.imageGenerationQuality, opencodeBaseUrl: '', opencodeEmbedded: true, opencodeBinaryPath: '', opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '', continueMode: 'chat' as const, subagentSmartRoutingEnabled: false, knowledgeEmbeddingEnabled: false, knowledgeEmbeddingBaseUrl: publicSettings.knowledgeEmbeddingBaseUrl, knowledgeEmbeddingModel: publicSettings.knowledgeEmbeddingModel, knowledgeRerankEnabled: false, knowledgeRerankEndpoint: publicSettings.knowledgeRerankEndpoint, knowledgeRerankModel: publicSettings.knowledgeRerankModel, workspacePath, apiKey: { action: 'keep' as const }, modelProfiles: [ { id: profile.id, name: profile.name, baseUrl: profile.baseUrl, modelName: profile.modelName, protocol: profile.protocol, authentication: profile.authentication, imageGenerationQuality: profile.imageGenerationQuality, apiKey: { action: 'keep' as const } } ], defaultModelProfileId: profileId, opencodeModelSource: { kind: 'platform' as const }, continueModelSource: { kind: 'platform' as const }, deepseekHarnessModelSource: { kind: 'platform' as const }, toolApproval: 'always' as const } } } describe('registerIpcHandlers lifecycle tracking', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() channelMocks.stop.mockResolvedValue(undefined) }) it('waits for a pending settings update and Runtime reload during cleanup', async () => { let releaseUpdate!: () => void const updateReleased = new Promise((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, toolApproval: 'always' } const update = vi.fn(async () => { await updateReleased return savedSettings }) let releaseReload!: () => void const reloadReleased = new Promise((resolve) => { releaseReload = resolve }) const onRuntimeSettingsChanged = vi.fn(async () => { await reloadReleased }) const dispose = registerIpcHandlers( window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', { captureRollback: vi.fn(async () => ({ publicSettings: savedSettings, restore: vi.fn(async () => savedSettings) })), update } as never, {} as never, { clear: vi.fn() } as never, {} as never, { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []), repairConversationRuntimeSelections: vi.fn() } as never, { clear: vi.fn() } as never, {} as never, onRuntimeSettingsChanged ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const input = { provider: savedSettings.provider, modelBaseUrl: savedSettings.modelBaseUrl, modelName: savedSettings.modelName, modelProtocol: savedSettings.modelProtocol, modelAuthentication: savedSettings.modelAuthentication, imageGenerationQuality: savedSettings.imageGenerationQuality, opencodeBaseUrl: savedSettings.opencodeBaseUrl, opencodeEmbedded: savedSettings.opencodeEmbedded, opencodeBinaryPath: savedSettings.opencodeBinaryPath, opencodeConfigPath: savedSettings.opencodeConfigPath, continueBinaryPath: savedSettings.continueBinaryPath, continueConfigPath: savedSettings.continueConfigPath, continueMode: savedSettings.continueMode, knowledgeEmbeddingEnabled: savedSettings.knowledgeEmbeddingEnabled, knowledgeEmbeddingBaseUrl: savedSettings.knowledgeEmbeddingBaseUrl, knowledgeEmbeddingModel: savedSettings.knowledgeEmbeddingModel, knowledgeRerankEnabled: savedSettings.knowledgeRerankEnabled, knowledgeRerankEndpoint: savedSettings.knowledgeRerankEndpoint, knowledgeRerankModel: savedSettings.knowledgeRerankModel, workspacePath: savedSettings.workspacePath, apiKey: { action: 'keep' as const }, toolApproval: savedSettings.toolApproval } try { const pendingUpdate = Promise.resolve( electronMocks.handlers.get(ipcChannels.runtimeSettingsUpdate)?.( event, input ) ) await vi.waitFor(() => expect(update).toHaveBeenCalledOnce()) let cleanupComplete = false const cleanup = dispose().then(() => { cleanupComplete = true }) await Promise.resolve() expect(cleanupComplete).toBe(false) releaseUpdate() await vi.waitFor(() => expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce() ) expect(cleanupComplete).toBe(false) releaseReload() await expect(pendingUpdate).resolves.toBe(savedSettings) await cleanup expect(cleanupComplete).toBe(true) } finally { releaseUpdate() releaseReload() await rm(workspace, { recursive: true, force: true }) } }) it('restores settings and preserves activation plus rollback failures', async () => { const workspace = await mkdtemp( join(tmpdir(), 'goodbuddy-ipc-settings-rollback-') ) const profileId = '00000000-0000-4000-8000-000000000091' const profile = { id: profileId, name: 'Default', baseUrl: 'https://model.example/v1', modelName: 'model', protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const, apiKeyConfigured: true, credentialSource: 'encrypted' as const } const previousSettings = { provider: 'model' as const, modelBaseUrl: profile.baseUrl, modelName: profile.modelName, modelProtocol: profile.protocol, modelAuthentication: profile.authentication, imageGenerationQuality: profile.imageGenerationQuality, opencodeBaseUrl: '', opencodeEmbedded: true, opencodeBinaryPath: '', opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '', continueMode: 'chat' as const, subagentSmartRoutingEnabled: false, knowledgeEmbeddingEnabled: false, knowledgeEmbeddingBaseUrl: 'https://embedding.example/v1', knowledgeEmbeddingModel: 'embedding', knowledgeEmbeddingApiKeyConfigured: false, knowledgeEmbeddingCredentialSource: 'none' as const, knowledgeRerankEnabled: false, knowledgeRerankEndpoint: 'https://rerank.example/v1', knowledgeRerankModel: 'rerank', knowledgeRerankApiKeyConfigured: false, knowledgeRerankCredentialSource: 'none' as const, workspacePath: workspace, apiKeyConfigured: true, credentialSource: 'encrypted' as const, modelProfiles: [profile], defaultModelProfileId: profileId, opencodeModelSource: { kind: 'platform' as const }, continueModelSource: { kind: 'platform' as const }, deepseekHarnessModelSource: { kind: 'platform' as const }, secureStorageAvailable: true, toolApproval: 'always' as const } const candidateSettings = { ...previousSettings, provider: 'opencode' as const } const update = vi.fn().mockResolvedValue(candidateSettings) const restore = vi.fn(async () => previousSettings) const activationError = new Error('candidate activation failed') const rollbackError = new Error('rollback activation failed') const onRuntimeSettingsChanged = vi .fn() .mockRejectedValueOnce(activationError) .mockRejectedValueOnce(rollbackError) const repairs = vi.fn() const webContents = { 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, { clear: vi.fn() } as never, {} as never, onRuntimeSettingsChanged ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const input = { provider: candidateSettings.provider, modelBaseUrl: profile.baseUrl, modelName: profile.modelName, modelProtocol: profile.protocol, modelAuthentication: profile.authentication, imageGenerationQuality: profile.imageGenerationQuality, opencodeBaseUrl: '', opencodeEmbedded: true, opencodeBinaryPath: '', opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '', continueMode: 'chat', subagentSmartRoutingEnabled: false, knowledgeEmbeddingEnabled: false, knowledgeEmbeddingBaseUrl: previousSettings.knowledgeEmbeddingBaseUrl, knowledgeEmbeddingModel: previousSettings.knowledgeEmbeddingModel, knowledgeRerankEnabled: false, knowledgeRerankEndpoint: previousSettings.knowledgeRerankEndpoint, knowledgeRerankModel: previousSettings.knowledgeRerankModel, workspacePath: workspace, apiKey: { action: 'keep' }, modelProfiles: [ { id: profile.id, name: profile.name, baseUrl: profile.baseUrl, modelName: profile.modelName, protocol: profile.protocol, authentication: profile.authentication, imageGenerationQuality: profile.imageGenerationQuality, apiKey: { action: 'keep' } } ], defaultModelProfileId: profileId, opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' }, deepseekHarnessModelSource: { kind: 'platform' }, toolApproval: 'always' } try { const result = Promise.resolve( electronMocks.handlers.get( ipcChannels.runtimeSettingsUpdate )?.(event, input) ) await expect(result).rejects.toMatchObject({ errors: [activationError, rollbackError] }) expect(update).toHaveBeenCalledOnce() expect(restore).toHaveBeenCalledOnce() expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(2) expect(repairs).not.toHaveBeenCalled() } finally { await dispose() await rm(workspace, { recursive: true, force: true }) } }) it('serializes failed settings, customization, and a later successful candidate', async () => { const workspace = await mkdtemp( join(tmpdir(), 'goodbuddy-ipc-settings-overlap-') ) const initial = runtimeUpdateFixture(workspace, 'model') const candidateA = runtimeUpdateFixture(workspace, 'opencode') const candidateB = runtimeUpdateFixture(workspace, 'continue') let currentSettings = initial.publicSettings const initialCustomization = { opencode: {}, continue: { presets: [] } } const candidateCustomization = { opencode: { defaultAgent: 'candidate-agent' }, continue: { presets: [] } } let currentCustomization = initialCustomization const captureRollback = vi.fn(async () => { const snapshot = currentSettings return { publicSettings: snapshot, restore: vi.fn(async () => { currentSettings = snapshot return snapshot }) } }) const update = vi.fn( async (input: { provider: 'model' | 'opencode' | 'continue' }) => { currentSettings = input.provider === 'opencode' ? candidateA.publicSettings : candidateB.publicSettings return currentSettings } ) const updateRuntimeCustomization = vi.fn( async (settings: typeof candidateCustomization) => { currentCustomization = settings return settings } ) let releaseCandidateA!: () => void const candidateABlocked = new Promise((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, { clear: vi.fn() } as never, {} as never, onRuntimeSettingsChanged, undefined, undefined, undefined, { reportRuntimeSelectionRepairs: reportRepairs } as never ) const event = { sender: webContents, senderFrame: webContents.mainFrame } try { const updateA = Promise.resolve( electronMocks.handlers.get( ipcChannels.runtimeSettingsUpdate )?.(event, candidateA.input) ) await vi.waitFor(() => expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce() ) const customizationUpdate = Promise.resolve( electronMocks.handlers.get( ipcChannels.runtimeCustomizationUpdate )?.(event, candidateCustomization) ) const updateB = Promise.resolve( electronMocks.handlers.get( ipcChannels.runtimeSettingsUpdate )?.(event, candidateB.input) ) await Promise.resolve() expect(captureRollback).toHaveBeenCalledOnce() expect(update).toHaveBeenCalledOnce() releaseCandidateA() await expect(updateA).rejects.toBe(candidateAError) await expect(customizationUpdate).resolves.toEqual( candidateCustomization ) await expect(updateB).resolves.toMatchObject({ provider: 'continue' }) expect(currentSettings.provider).toBe('continue') expect(currentCustomization).toEqual(candidateCustomization) expect(captureRollback).toHaveBeenCalledTimes(2) expect(update).toHaveBeenCalledTimes(2) expect(updateRuntimeCustomization).toHaveBeenCalledOnce() expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(4) expect(repairs).toHaveBeenCalledOnce() expect(repairs).toHaveBeenCalledWith( expect.objectContaining({ provider: 'continue' }) ) expect(reportRepairs).toHaveBeenCalledOnce() } finally { releaseCandidateA() await dispose() await rm(workspace, { recursive: true, force: true }) } }) }) describe('registerIpcHandlers knowledge snapshot ontology', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('exposes per-library ontology settings and rebuild state', async () => { const libraryId = '11111111-1111-4111-8111-111111111111' const knowledgeService = { database: { externalStore: { listBindings: () => [] } }, snapshot: vi.fn(() => ({ libraries: [ { id: libraryId, name: 'Ontology', description: '', storageMode: 'reference', graphEnabled: true, graphStrategy: 'rules', sourceCount: 0, documentCount: 0, indexedDocumentCount: 0, retrievalSettings: {}, chunkingSettings: {}, chunkingRebuildRequired: false, ontologySettings: defaultKnowledgeOntologySettings, ontologyRebuildRequired: true, updatedAt: '2026-08-12T00:00:00.000Z' } ], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] })) } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => undefined) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } expect( electronMocks.handlers.get(ipcChannels.knowledgeSnapshot)?.( event, libraryId ) ).toMatchObject({ libraries: [ { id: libraryId, ontologySettings: defaultKnowledgeOntologySettings, ontologyRebuildRequired: true } ] }) await dispose() }) }) describe('registerIpcHandlers knowledge embedding index', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('validates and forwards library-scoped index actions', async () => { const libraryId = '11111111-1111-4111-8111-111111111111' const jobId = '22222222-2222-4222-8222-222222222222' const snapshot = { knowledgeBaseId: libraryId, enabled: true, configuration: { provider: 'openai-compatible', model: 'embed-v1', endpoint: 'http://127.0.0.1:11434/v1/embeddings', credentialConfigured: false }, coverage: { total: 2, indexed: 1, missing: 1, error: 0 }, indexStatus: { job: null } } const knowledgeService = { getEmbeddingIndexSnapshot: vi.fn(async () => snapshot), rebuildEmbeddingIndex: vi.fn(async () => snapshot), cancelEmbeddingIndex: vi.fn(async () => true) } const settingsStore = { getResolvedSettings: vi.fn(async () => ({ knowledgeEmbeddingEnabled: true })), getPublicSettings: vi.fn(async () => ({ knowledgeEmbeddingModel: 'embed-v1', knowledgeEmbeddingBaseUrl: 'http://127.0.0.1:11434/v1/embeddings', knowledgeEmbeddingApiKeyConfigured: false })) } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => undefined) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } await expect( electronMocks.handlers.get( ipcChannels.knowledgeEmbeddingIndexGet )?.(event, { knowledgeBaseId: libraryId }) ).resolves.toEqual(snapshot) expect( knowledgeService.getEmbeddingIndexSnapshot ).toHaveBeenCalledWith( libraryId, snapshot.configuration ) await expect( electronMocks.handlers.get( ipcChannels.knowledgeEmbeddingIndexRebuild )?.(event, { knowledgeBaseId: libraryId }) ).resolves.toEqual(snapshot) expect(knowledgeService.rebuildEmbeddingIndex).toHaveBeenCalledWith( libraryId, snapshot.configuration ) await expect( electronMocks.handlers.get( ipcChannels.knowledgeEmbeddingIndexCancel )?.(event, { knowledgeBaseId: libraryId, jobId }) ).resolves.toBe(true) expect(knowledgeService.cancelEmbeddingIndex).toHaveBeenCalledWith( libraryId, jobId ) await expect( electronMocks.handlers.get( ipcChannels.knowledgeEmbeddingIndexGet )?.(event, { knowledgeBaseId: 'not-a-uuid' }) ).rejects.toThrow() await expect( electronMocks.handlers.get( ipcChannels.knowledgeEmbeddingIndexGet )?.( { sender: {}, senderFrame: webContents.mainFrame }, { knowledgeBaseId: libraryId } ) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') await dispose() }) }) describe('registerIpcHandlers knowledge task actions', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('validates and forwards bounded cancel and retry actions', async () => { const taskId = '33333333-3333-4333-8333-333333333333' const knowledgeService = { cancelTask: vi.fn(async () => true), retryTask: vi.fn(async () => undefined) } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => undefined) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } await expect( electronMocks.handlers.get( ipcChannels.knowledgeTaskCancel )?.(event, { taskId }) ).resolves.toBe(true) expect(knowledgeService.cancelTask).toHaveBeenCalledWith(taskId) await expect( electronMocks.handlers.get( ipcChannels.knowledgeTaskRetry )?.(event, { taskId }) ).resolves.toBeUndefined() expect(knowledgeService.retryTask).toHaveBeenCalledWith(taskId) await expect( electronMocks.handlers.get( ipcChannels.knowledgeTaskCancel )?.(event, { taskId: 'not-a-uuid' }) ).rejects.toThrow() await expect( electronMocks.handlers.get( ipcChannels.knowledgeTaskRetry )?.( { sender: {}, senderFrame: webContents.mainFrame }, { taskId } ) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') await dispose() }) }) describe('registerIpcHandlers model ZIP dialogs', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('imports and exports speech and OCR ZIPs through trusted dialogs', async () => { const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => undefined), undefined, undefined, undefined, undefined, undefined, undefined, speechModelManager as never, undefined, undefined, undefined, undefined, undefined, documentParsingService as never, documentOcrModelManager as never ) await expect( electronMocks.handlers.get( ipcChannels.speechModelsImportArchive )?.(event, { modelId: 'speech-model' }) ).resolves.toBeUndefined() expect(speechModelManager.importArchive).not.toHaveBeenCalled() electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: ['C:\\transfer\\speech.zip'] }) await expect( electronMocks.handlers.get( ipcChannels.speechModelsImportArchive )?.(event, { modelId: 'speech-model' }) ).resolves.toBe(speechSnapshot) expect(electronMocks.showOpenDialog).toHaveBeenLastCalledWith( window, expect.objectContaining({ properties: ['openFile'], filters: [ { name: 'GoodBuddy 模型 ZIP', extensions: ['zip'] } ] }) ) expect(speechModelManager.importArchive).toHaveBeenCalledWith( 'speech-model', 'C:\\transfer\\speech.zip' ) electronMocks.showSaveDialog.mockResolvedValueOnce({ canceled: false, filePath: 'C:\\transfer\\speech-model' }) await expect( electronMocks.handlers.get( ipcChannels.speechModelsExportArchive )?.(event, { modelId: 'speech-model' }) ).resolves.toBe(speechSnapshot) expect(speechModelManager.exportArchive).toHaveBeenCalledWith( 'speech-model', 'C:\\transfer\\speech-model.zip' ) electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: ['C:\\transfer\\ocr.zip'] }) await expect( electronMocks.handlers.get( ipcChannels.documentOcrModelsImportArchive )?.(event, { modelId: 'ocr-model' }) ).resolves.toBe(ocrSnapshot) expect(documentOcrModelManager.importArchive).toHaveBeenCalledWith( 'ocr-model', 'C:\\transfer\\ocr.zip' ) electronMocks.showSaveDialog.mockResolvedValueOnce({ canceled: false, filePath: 'C:\\transfer\\ocr-model.ZIP' }) await expect( electronMocks.handlers.get( ipcChannels.documentOcrModelsExportArchive )?.(event, { modelId: 'ocr-model' }) ).resolves.toBe(ocrSnapshot) expect(documentOcrModelManager.exportArchive).toHaveBeenCalledWith( 'ocr-model', 'C:\\transfer\\ocr-model.ZIP' ) await expect( electronMocks.handlers.get( ipcChannels.speechModelsExportArchive )?.( { sender: {}, senderFrame: webContents.mainFrame }, { modelId: 'speech-model' } ) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') await expect( electronMocks.handlers.get( ipcChannels.documentOcrModelsImportArchive )?.(event, {}) ).rejects.toThrow() await dispose() }) }) describe('registerIpcHandlers document parsing', () => { const temporaryDirectories: string[] = [] afterEach(async () => { electronMocks.handlers.clear() vi.clearAllMocks() await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) it('tests an explicit scenario and parses artifact PDFs through the shared service', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-ipc-document-parsing-') ) temporaryDirectories.push(directory) const diagnosticPath = join(directory, 'diagnostic.pdf') const artifactPath = join(directory, 'artifact.pdf') const oversizedArtifactPath = join(directory, 'oversized.pdf') await writeFile(diagnosticPath, 'diagnostic') await writeFile(artifactPath, 'artifact') await writeFile( oversizedArtifactPath, Buffer.alloc(20 * 1024 * 1024 + 1) ) const diagnostic = { fileName: 'diagnostic.pdf', sourceFormat: 'PDF', pageCount: 1, ocrPageCount: 0, characterCount: 4, method: 'native', durationMs: 1, preview: 'text', warnings: [] } const documentParsingService = { diagnose: vi.fn(async () => diagnostic), parse: vi.fn(async () => ({ title: 'artifact', sourceFormat: '.pdf', content: 'parsed artifact', sections: [ { locator: '第 1 页', content: 'parsed artifact', method: 'native' } ], pageCount: 1, warnings: [] })) } const createInlineArtifact = vi.fn((input) => input) const assistantDatabase = { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []), createInlineArtifact } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => undefined), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, documentParsingService as never ) electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: [diagnosticPath] }) await expect( electronMocks.handlers.get( ipcChannels.documentParsingTest )?.(event, { purpose: 'knowledge-index' }) ).resolves.toBe(diagnostic) expect(documentParsingService.diagnose).toHaveBeenCalledWith( 'diagnostic.pdf', expect.any(Buffer), 'knowledge-index', expect.any(String) ) await expect( electronMocks.handlers.get( ipcChannels.documentParsingTest )?.(event, { purpose: 'diagnostic' }) ).rejects.toThrow() electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: [artifactPath] }) await expect( electronMocks.handlers.get( ipcChannels.artifactsImportFiles )?.(event) ).resolves.toEqual([ expect.objectContaining({ title: 'artifact.pdf', content: '## 第 1 页\n\nparsed artifact' }) ]) expect(documentParsingService.parse).toHaveBeenCalledWith( 'artifact.pdf', expect.any(Buffer), 'artifact-import' ) electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: [oversizedArtifactPath] }) await expect( electronMocks.handlers.get( ipcChannels.artifactsImportFiles )?.(event) ).rejects.toThrow('超过大小限制') expect(documentParsingService.parse).toHaveBeenCalledOnce() await dispose() }) }) describe('registerIpcHandlers connection tests', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('tests a resolved model profile without invoking the selected Continue Runtime', async () => { const profileId = '00000000-0000-4000-8000-000000000001' const profile = { id: profileId, name: '默认模型', baseUrl: 'https://models.example', modelName: 'good-model', protocol: 'anthropic-messages', authentication: 'api-key', imageGenerationQuality: 'auto', apiKey: 'main-only-secret' as string | undefined } const resolvedSettings = { provider: 'continue', workspacePath: 'C:\\Workspace', modelProfiles: [profile], defaultModelProfileId: profileId } const modelRuntime = { testConnection: vi.fn(async () => ({ id: 'model', label: 'good-model', available: true, supportsToolExecution: true, detail: 'Ready' })), getStatus: vi.fn(), dispose: vi.fn(async () => undefined) } runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue( modelRuntime ) const continueRuntime = { testConnection: vi.fn(async () => { throw new Error('Continue 配置不可用') }), getStatus: vi.fn(), dispose: vi.fn(async () => undefined) } const getResolvedSettings = vi.fn(async () => resolvedSettings) const webContents = { 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 approvalBroker = { clear: vi.fn() } const dispose = registerIpcHandlers( window as never, continueRuntime as never, 'CommandOrControl+Shift+Space', { getResolvedSettings } as never, {} as never, contextManager as never, {} as never, { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []) } as never, approvalBroker as never, {} as never, vi.fn(async () => {}) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsTestModel )?.(event, profileId) ).resolves.toMatchObject({ id: 'model', label: 'good-model', available: true }) expect(getResolvedSettings).toHaveBeenCalledOnce() expect( runtimeFactoryMocks.createModelProfileRuntime ).toHaveBeenCalledWith('C:\\Workspace', resolvedSettings, profile) expect(modelRuntime.testConnection).toHaveBeenCalledOnce() expect(modelRuntime.dispose).toHaveBeenCalledOnce() expect(continueRuntime.testConnection).not.toHaveBeenCalled() await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsTestModel )?.(event, 'not-a-profile-id') ).rejects.toThrow() expect(getResolvedSettings).toHaveBeenCalledOnce() getResolvedSettings.mockResolvedValueOnce({ ...resolvedSettings, modelProfiles: [{ ...profile, apiKey: undefined }] }) runtimeFactoryMocks.createModelProfileRuntime.mockClear() modelRuntime.testConnection.mockClear() await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsTestModel )?.(event, profileId) ).rejects.toThrow('模型连接“默认模型”未配置 API Key') expect(getResolvedSettings).toHaveBeenCalledTimes(2) expect( runtimeFactoryMocks.createModelProfileRuntime ).not.toHaveBeenCalled() expect(modelRuntime.testConnection).not.toHaveBeenCalled() const noAuthProfile = { ...profile, authentication: 'none', apiKey: undefined } const noAuthSettings = { ...resolvedSettings, modelProfiles: [noAuthProfile] } getResolvedSettings.mockResolvedValueOnce(noAuthSettings) await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsTestModel )?.(event, profileId) ).resolves.toMatchObject({ available: true }) expect( runtimeFactoryMocks.createModelProfileRuntime ).toHaveBeenCalledWith( 'C:\\Workspace', noAuthSettings, noAuthProfile ) expect(modelRuntime.testConnection).toHaveBeenCalledOnce() await dispose() }) it('validates and tests the selected OpenCode or Continue Runtime', async () => { const selectedRuntimes = { getRuntime: vi.fn(), getStatus: vi.fn(), testStatus: vi.fn(async () => ({ id: 'opencode', label: 'OpenCode', available: true, supportsToolExecution: true, detail: 'Ready' })), releaseConversation: vi.fn(async () => undefined) } const fallbackRuntime = { testConnection: vi.fn(async () => { throw new Error('不应测试旧的全局 Runtime') }), getStatus: vi.fn(), dispose: vi.fn(async () => undefined) } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, selectedRuntimes as never ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const selection = { provider: 'opencode' as const, profileId: '00000000-0000-4000-8000-000000000001' } await expect( electronMocks.handlers.get(ipcChannels.runtimeSettingsTest)?.( event, selection ) ).resolves.toMatchObject({ id: 'opencode', available: true }) expect(selectedRuntimes.testStatus).toHaveBeenCalledWith(selection) expect(fallbackRuntime.testConnection).not.toHaveBeenCalled() await expect( electronMocks.handlers.get(ipcChannels.runtimeSettingsTest)?.( event, { provider: 'opencode', profileId: 'not-a-uuid' } ) ).rejects.toThrow() expect(selectedRuntimes.testStatus).toHaveBeenCalledOnce() await dispose() }) }) describe('registerIpcHandlers Runtime config actions', () => { const temporaryDirectories: string[] = [] afterEach(async () => { electronMocks.handlers.clear() vi.clearAllMocks() await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) it('opens only configured files or the fixed Runtime config directory', async () => { const temporaryDirectory = await mkdtemp( join(tmpdir(), 'goodbuddy-runtime-config-') ) temporaryDirectories.push(temporaryDirectory) const configPath = join(temporaryDirectory, 'config.yaml') await writeFile(configPath, 'name: Test', 'utf8') const getPublicSettings = vi.fn(async () => ({ opencodeConfigPath: '', continueConfigPath: process.execPath, configured: { opencodeConfigPath: '', continueConfigPath: configPath } })) const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const canonicalConfigPath = await realpath(configPath) await electronMocks.handlers.get( ipcChannels.runtimeSettingsOpenConfig )?.(event, { runtime: 'continue', action: 'open-file' }) expect(electronMocks.openPath).toHaveBeenCalledWith( canonicalConfigPath ) await electronMocks.handlers.get( ipcChannels.runtimeSettingsOpenConfig )?.(event, { runtime: 'continue', action: 'show-file' }) expect(electronMocks.showItemInFolder).toHaveBeenCalledWith( canonicalConfigPath ) await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsOpenConfig )?.(event, { runtime: 'continue', action: 'open-file', path: join(temporaryDirectory, 'attacker-controlled.yaml') }) ).rejects.toThrow() expect(getPublicSettings).toHaveBeenCalledTimes(2) getPublicSettings.mockResolvedValueOnce({ opencodeConfigPath: '', continueConfigPath: configPath, configured: { opencodeConfigPath: '', continueConfigPath: process.execPath } }) await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsOpenConfig )?.(event, { runtime: 'continue', action: 'open-file' }) ).rejects.toThrow('Runtime 配置文件类型不支持直接打开') expect(electronMocks.openPath).toHaveBeenCalledTimes(1) const previousXdgConfigHome = process.env.XDG_CONFIG_HOME process.env.XDG_CONFIG_HOME = temporaryDirectory try { await electronMocks.handlers.get( ipcChannels.runtimeSettingsOpenConfig )?.(event, { runtime: 'opencode', action: 'open-directory' }) expect(electronMocks.openPath).toHaveBeenLastCalledWith( await realpath(join(temporaryDirectory, 'opencode')) ) } finally { if (previousXdgConfigHome === undefined) { delete process.env.XDG_CONFIG_HOME } else { process.env.XDG_CONFIG_HOME = previousXdgConfigHome } } await expect( electronMocks.handlers.get( ipcChannels.runtimeSettingsOpenConfig )?.( { sender: {}, senderFrame: webContents.mainFrame }, { runtime: 'continue', action: 'open-directory' } ) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') await dispose() }) }) describe('registerIpcHandlers window controls', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('restricts custom chrome controls to the trusted main window', async () => { let maximized = false const listeners = new Map 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } electronMocks.handlers.get(ipcChannels.windowMinimize)?.(event) electronMocks.handlers.get( ipcChannels.windowToggleMaximize )?.(event) listeners.get('maximize')?.() electronMocks.handlers.get(ipcChannels.windowClose)?.(event) expect(window.minimize).toHaveBeenCalledOnce() expect(window.maximize).toHaveBeenCalledOnce() expect(webContents.send).toHaveBeenCalledWith( ipcChannels.windowMaximizedChanged, true ) expect(window.close).toHaveBeenCalledOnce() expect(() => electronMocks.handlers .get(ipcChannels.windowIsMaximized) ?.({ sender: {}, senderFrame: webContents.mainFrame }) ).toThrow('拒绝来自未知窗口的 IPC 请求') await dispose() expect(window.removeListener).toHaveBeenCalledWith( 'maximize', listeners.get('maximize') ) }) }) describe('registerIpcHandlers workspace files', () => { const temporaryDirectories: string[] = [] afterEach(async () => { electronMocks.handlers.clear() vi.clearAllMocks() await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) it('resolves the project root and validates file requests', async () => { const rootPath = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-files-')) temporaryDirectories.push(rootPath) await writeFile(join(rootPath, 'README.md'), '# GoodBuddy\n') const projectId = '00000000-0000-4000-8000-000000000101' const assistantDatabase = { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []), getProject: vi.fn(() => ({ id: projectId, rootPath })) } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}), undefined, undefined, undefined, undefined, { onChanged: vi.fn(), get: vi.fn(async () => { throw new Error('settings unavailable') }) } as never ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const list = await electronMocks.handlers.get( ipcChannels.workspaceDirectoryList )?.(event, { projectId, path: '' }) const preview = await electronMocks.handlers.get( ipcChannels.workspaceFileRead )?.(event, { projectId, path: 'README.md' }) expect(list).toMatchObject({ entries: [ { name: 'README.md', path: 'README.md', type: 'file' } ] }) expect(preview).toMatchObject({ path: 'README.md', content: '# GoodBuddy\n', mimeType: 'text/markdown' }) await expect( electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.(event, { projectId, path: '../outside.txt' }) ).rejects.toThrow('路径必须是工作区内的相对路径') expect(assistantDatabase.getProject).toHaveBeenCalledWith(projectId) await dispose() }) it('fails remote workspace requests before filesystem, Git, or shell access', async () => { const projectId = '00000000-0000-4000-8000-000000000102' const assistantDatabase = { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []), getProject: vi.fn(() => ({ id: projectId, name: 'Remote', description: '', rootPath: '/srv/project', executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000202', remoteRootPath: '/srv/project' }, defaultWorkMode: 'ask', kind: 'user', status: 'active', createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z' })) } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}), undefined, undefined, undefined, undefined, { onChanged: vi.fn(), get: vi.fn(async () => ({ remoteProjectsEnabled: true })) } as never ) const event = { sender: webContents, senderFrame: webContents.mainFrame } for (const [channel, input] of [ [ipcChannels.workspaceChangesGet, projectId], [ipcChannels.workspaceFileDiff, { projectId, path: 'deleted.txt' }], [ ipcChannels.workspaceDirectoryList, { projectId, path: '' } ], [ ipcChannels.workspaceFileRead, { projectId, path: 'README.md' } ], [ ipcChannels.workspacePathOpen, { projectId, path: 'README.md', type: 'file' } ] ] as const) { await expect( electronMocks.handlers.get(channel)?.(event, input) ).rejects.toThrow('远程执行空间尚不可用') } expect(electronMocks.openPath).not.toHaveBeenCalled() await dispose() }) }) describe('registerIpcHandlers token usage', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('validates execution statistics scope and sender before reading evidence', async () => { const summary = { durationMs: 5000, requestCount: 2, incompleteRequestCount: 1, activeRequestCount: 0, asOf: 10000, taskDurations: [{ id: 'task', durationMs: 5000, incompleteRequestCount: 1 }] } const assistantDatabase = { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []), getExecutionStatsAsync: vi.fn(async () => summary) } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) ) try { const handler = electronMocks.handlers.get(ipcChannels.tasksExecutionStats)! const event = { sender: webContents, senderFrame: webContents.mainFrame } const id = '00000000-0000-4000-8000-000000000301' for (const scope of [{ conversationId: id }, { projectId: id }]) { await expect(handler(event, scope)).resolves.toEqual(summary) expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenLastCalledWith(scope, new Set(), expect.stringContaining('execution-stats-worker.js')) } expect(() => handler(event, {})).toThrow() expect(() => handler(event, { projectId: id, conversationId: id })).toThrow() expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow() expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenCalledTimes(2) } finally { await dispose() } }) it('returns the database token summary to a trusted renderer', async () => { const summary = { totals: { callCount: 2, input: 120, output: 30, cacheRead: 10, cacheWrite: 5, totalTokens: 165 }, records: [] } const assistantDatabase = { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []), getTokenUsageSummary: vi.fn(() => summary) } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) ) const handler = electronMocks.handlers.get( ipcChannels.tokenUsageSummary ) expect(handler).toBeDefined() expect( handler?.({ sender: webContents, senderFrame: webContents.mainFrame }) ).toBe(summary) expect(assistantDatabase.getTokenUsageSummary).toHaveBeenCalledOnce() await dispose() }) }) describe('registerIpcHandlers local conversation persistence', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it.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, { clear: vi.fn() } 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 | 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, { clear: vi.fn() } as never, {} 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(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() expect(() => pin(event, { conversationId, pinned: 'yes' })).toThrow() expect(() => pin({ ...event, sender: {} }, { conversationId, pinned: true })).toThrow() expect(() => pin(event, { conversationId: '00000000-0000-4000-8000-000000000399', pinned: true })).toThrow() expect(webContents.send).not.toHaveBeenCalled() expect(database.getConversation(conversationId)).toEqual(before) expect(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 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' }, workMode: 'ask', includeMemoryContext: true, prompt: queuedItem.label, attachments: [ { id: queuedAttachmentId, name: 'queued.txt', size: 6, preview: 'queued', kind: 'text' } ], knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto' }, serializedContexts: '[]' }) ), saveLocalConversations: vi.fn(), branchLocalConversation: vi.fn((input) => ({ id: '00000000-0000-4000-8000-000000000305', branch: { sourceConversationId: input.sourceConversationId, sourceTitle: '增量会话' }, title: input.title, updatedAt: 2, messages: [] })), deleteLocalConversation: vi.fn(() => true) } const contextManager = { clear: vi.fn(), remove: vi.fn() } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } const messageId = '00000000-0000-4000-8000-000000000302' const batch = [ { header: { id: conversationId, title: '增量会话', updatedAt: 1 }, messages: [ { id: messageId, role: 'assistant' as const, content: '增量内容', createdAt: 1, state: 'streaming' as const, tools: [ { callId: 'remote-tool-call', name: '远程工具', state: 'completed' as const, summary: '完整远程工具结果', output: '远程输出'.repeat(10_000) } ] } ] } ] expect( electronMocks.handlers.get( ipcChannels.conversationsSaveLocal )?.(event, batch) ).toBeUndefined() expect( assistantDatabase.saveLocalConversations ).toHaveBeenCalledWith(batch) const branchInput = { sourceConversationId: conversationId, title: '增量会话 · 分支' } expect( electronMocks.handlers.get( ipcChannels.conversationsBranchLocal )?.(event, branchInput) ).toMatchObject({ branch: { sourceConversationId: conversationId }, title: branchInput.title }) expect( assistantDatabase.branchLocalConversation ).toHaveBeenCalledWith(branchInput) expect( electronMocks.handlers.get( ipcChannels.conversationsDeleteLocal )?.(event, conversationId) ).toBe(true) expect( assistantDatabase.deleteLocalConversation ).toHaveBeenCalledWith(conversationId) expect(contextManager.remove).toHaveBeenCalledWith( queuedAttachmentId ) expect(() => electronMocks.handlers.get( ipcChannels.conversationsSaveLocal )?.(event, [ { ...batch[0], header: { ...batch[0]!.header, remote: { channel: 'weixin', accountDisplay: 'remote', conversationType: 'direct' } } } ]) ).toThrow() expect(() => electronMocks.handlers.get( ipcChannels.conversationsBranchLocal )?.(event, { sourceConversationId: 'not-a-uuid', title: '无效分支' }) ).toThrow() expect(() => electronMocks.handlers.get( ipcChannels.conversationsDeleteLocal )?.(event, 'not-a-uuid') ).toThrow() await dispose() }) it('waits for the renderer persistence acknowledgement before removing handlers', async () => { const assistantDatabase = { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []) } const webContents = { 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, { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) ) const event = { sender: webContents, senderFrame: webContents.mainFrame } electronMocks.handlers.get( ipcChannels.appRendererPersistenceReady )?.(event) const disposal = dispose() await vi.waitFor(() => expect(webContents.send).toHaveBeenCalledWith( ipcChannels.appRendererPersistenceRequest, expect.any(String) ) ) expect( electronMocks.handlers.has(ipcChannels.conversationsSaveLocal) ).toBe(true) const requestId = webContents.send.mock.calls.find( ([channel]) => channel === ipcChannels.appRendererPersistenceRequest )?.[1] expect(requestId).toEqual(expect.any(String)) electronMocks.handlers.get( ipcChannels.appRendererPersistenceComplete )?.(event, requestId) await disposal expect( electronMocks.handlers.has(ipcChannels.conversationsSaveLocal) ).toBe(false) }) }) describe('registerIpcHandlers Runtime customization', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() }) it('validates customization, native inventory, and trusted manual compaction', async () => { const projectId = '00000000-0000-4000-8000-000000000601' const conversationId = '00000000-0000-4000-8000-000000000602' const requestId = '00000000-0000-4000-8000-000000000603' const messageIds = [ '00000000-0000-4000-8000-000000000604', '00000000-0000-4000-8000-000000000605' ] const customization = { opencode: { defaultAgent: 'planner' }, continue: { presets: [] } } const snapshot = { provider: 'opencode' as const, available: true, inventoryStatus: 'available' as const, detail: 'OpenCode native capabilities are ready', agents: [ { id: 'planner', name: 'Planner', mode: 'primary' as const, native: true, hidden: false } ], tools: [ { id: 'edit', name: 'edit', kind: 'write' as const, source: 'runtime' as const, ask: 'blocked' as const, execute: 'allowed' as const } ], toolsSupported: true, commands: [], lsp: [], formatters: [], mcpServers: [], skills: [], rules: [], prompts: [], resources: [], resourcesSupported: true, context: { strategy: 'native' as const, manualCompact: true, detail: 'OpenCode manages native context' } } const webContents = { 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((resolve) => { releaseFirstCompaction = resolve }) const compactionOutcome = { result: { provider: 'opencode' as const, strategy: 'native' as const, compacted: true, detail: 'OpenCode compacted the conversation' } } const selectedRuntimes = { getNativeSnapshot: vi.fn(async () => snapshot), compactConversation: vi .fn() .mockImplementationOnce(async () => { await firstCompactionBlocked return compactionOutcome }) .mockResolvedValue(compactionOutcome) } const approvalBroker = { clear: vi.fn() } const onRuntimeSettingsChanged = vi.fn(async () => undefined) const contextManager = { clear: vi.fn() } const dispose = registerIpcHandlers( window as never, { capability: 'text' } as never, 'CommandOrControl+Shift+Space', settingsStore as never, {} as never, contextManager as never, {} as never, assistantDatabase as never, approvalBroker as never, {} as never, onRuntimeSettingsChanged, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, selectedRuntimes as never ) const event = { sender: webContents, senderFrame: webContents.mainFrame } 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() expect(approvalBroker.clear).not.toHaveBeenCalled() const activationError = new Error('customization activation failed') onRuntimeSettingsChanged .mockRejectedValueOnce(activationError) .mockResolvedValueOnce(undefined) const failedCustomization = { ...customization, opencode: { defaultAgent: 'candidate-agent' } } await expect( electronMocks.handlers.get( ipcChannels.runtimeCustomizationUpdate )?.(event, failedCustomization) ).rejects.toBe(activationError) expect( settingsStore.updateRuntimeCustomization ).toHaveBeenNthCalledWith(2, failedCustomization) expect( settingsStore.updateRuntimeCustomization ).toHaveBeenNthCalledWith(3, customization) expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(3) await expect( electronMocks.handlers.get( ipcChannels.runtimeCustomizationUpdate )?.(event, { ...customization, unknown: true }) ).rejects.toThrow() await expect( electronMocks.handlers.get( ipcChannels.runtimeNativeSnapshot )?.(event, { provider: 'opencode', projectId }) ).resolves.toEqual(snapshot) expect(selectedRuntimes.getNativeSnapshot).toHaveBeenCalledWith( { provider: 'opencode' }, expect.objectContaining({ kind: 'local', rootPath: 'C:\\ProjectWorkspace' }) ) 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, onBeforeClearLocalData?: () => Promise, toolApproval: 'always' | 'policy' = 'always', subagentService?: Record, smartRoutingEnabled = false, selectedRuntimes?: Record, knowledgeServiceOverride?: Record, knowledgeGateway?: Record, magicNotesEnabled = false, goodbuddyConfigService?: Record, capabilityServiceOverride?: Record, browserControl?: Record, imageService?: ImageGenerationService, imageDatabase?: AssistantDatabase, heartbeatEnabled?: boolean, obsidianService?: ObsidianService, nativeClientCoordinator?: Parameters[45], nativeTerminalManager?: Parameters[41], applicationSettingsStore?: ApplicationSettingsStore ) { const assistantDatabase = { createTask: vi.fn(), appendTaskEvent: vi.fn(), appendRemoteTaskEventOnce: vi.fn< (input: RemoteTaskEventMockInput) => boolean >(() => true), appendRemoteConversationTaskEventOnce: vi.fn(() => true), listConversations: vi.fn<() => ConversationSnapshot[]>(() => []), listConversationSummaries: vi.fn<(ids?: string[]) => ConversationSnapshot[]>(() => []), listRecoverableRemoteTasks: vi.fn< () => Array<{ taskId: string projectId: string conversationId: string currentUserMessageId: string currentAssistantMessageId: string instructions: string workMode: 'ask' | 'execute' status: 'running' | 'waiting_approval' | 'interrupted' }> >(() => []), getHighestCommittedRemoteTaskEventSequenceForTask: vi.fn( () => '0' ), hasRemoteResponseTextAfterToolFailure: vi.fn(() => false), getRemoteTaskActivityStates: vi.fn(() => ({ tools: new Map(), subagents: new Map() })), endRecoverableRemoteTask: vi.fn(), recordRemoteTaskQuestionArrival: vi.fn(), recordRemoteTaskQuestionAnswer: vi.fn(), getTask: vi.fn(), updateTaskStatus: vi.fn(), createTextArtifact: vi.fn(), createImageArtifact: vi.fn(() => ({ id: '00000000-0000-4000-8000-000000000499', title: '生成图片' })), getArtifact: vi.fn(), listProjects: vi.fn<() => AssistantProject[]>(() => [ { id: '00000000-0000-4000-8000-000000000401', name: '企业微信', description: '企业微信远程消息与受控任务', rootPath: 'C:\\ProjectWorkspace', executionSpace: { kind: 'local', rootPath: 'C:\\ProjectWorkspace' }, defaultWorkMode: 'ask', runtimeSelection: { provider: 'model', 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>>(() => []), getExpert: vi.fn(), getConversation: vi.fn< (conversationId: string) => Record >((conversationId) => ({ id: conversationId, runtimeSelection: { provider: 'opencode' }, messages: [] })), getProject: vi.fn< (projectId: string) => Record >((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 approvalBroker = { request: vi.fn(), respond: vi.fn(), clear: vi.fn() } const getResolvedSettings = vi.fn( async (): Promise> => ({ toolApproval, subagentSmartRoutingEnabled: smartRoutingEnabled }) ) const getPolicySettings = vi.fn( async (): Promise> => ({ toolApproval, subagentSmartRoutingEnabled: smartRoutingEnabled }) ) const getApplicationSettings = vi.fn( async (): Promise> => ({ magicNotesEnabled, heartbeatEnabled, remoteProjectsEnabled: true }) ) const onRuntimeSettingsChanged = vi.fn(async () => {}) const args: Parameters = [ window as never, runtime as never, 'CommandOrControl+Shift+Space', { getPolicySettings, getResolvedSettings, getRuntimeCustomization: vi.fn(async () => ({ opencode: {}, continue: { presets: [] } })), updateRuntimeCustomization: vi.fn( async (settings: unknown) => settings ), captureRollback: vi.fn(async () => ({ publicSettings: defaultRuntimeSettings, restore: async () => defaultRuntimeSettings })), update: vi.fn(async (settings: unknown) => settings) } as never, (capabilityServiceOverride ?? { setWebSearchEnabled: vi.fn(async () => ({})) }) as never, contextManager as never, (knowledgeServiceOverride ?? { database: { listKnowledgeBases: vi.fn(() => []) } }) as never, (imageDatabase ?? assistantDatabase) as never, approvalBroker as never, {} as never, onRuntimeSettingsChanged, onBeforeClearLocalData, browserControl as never, subagentService as never, undefined, { get: getApplicationSettings, onChanged: vi.fn() } as never, undefined, undefined, undefined, selectedRuntimes as never, undefined, knowledgeGateway as never, undefined, undefined, undefined, undefined, undefined, goodbuddyConfigService as never ] args[43] = imageService args[44] = obsidianService args[45] = nativeClientCoordinator args[41] = nativeTerminalManager if (applicationSettingsStore) args[15] = applicationSettingsStore const dispose = registerIpcHandlers(...args) return { approvalBroker, assistantDatabase, contextManager, dispose, getApplicationSettings, getPolicySettings, getResolvedSettings, onRuntimeSettingsChanged, clearHandler: electronMocks.handlers.get( ipcChannels.appClearLocalData ), handler: electronMocks.handlers.get(ipcChannels.agentRun), statusHandler: electronMocks.handlers.get(ipcChannels.agentStatus), cancelHandler: electronMocks.handlers.get(ipcChannels.agentCancel), recoveryGetHandler: electronMocks.handlers.get( ipcChannels.remoteProjectRecoveryGet ), recoveryRetryHandler: electronMocks.handlers.get( ipcChannels.remoteProjectRecoveryRetry ), branchHandler: electronMocks.handlers.get( ipcChannels.conversationsBranchLocal ), knowledgeSearchHandler: electronMocks.handlers.get( ipcChannels.knowledgeSearch ), knowledgeRebuildHandler: electronMocks.handlers.get( ipcChannels.knowledgeRebuildLibrary ), knowledgeCancelRebuildHandler: electronMocks.handlers.get( ipcChannels.knowledgeCancelRebuild ), webContents } } const trustedEvent = (webContents: { mainFrame: { url: string } }) => ({ sender: webContents, senderFrame: webContents.mainFrame }) 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, 'always', 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, workMode: 'ask' }, new AbortController().signal)).resolves.toMatchObject({ status }) } else { const requestId = crypto.randomUUID() await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: crypto.randomUUID(), prompt: 'Read only', workMode: 'ask' }) 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 ) { const projectId = '00000000-0000-4000-8000-000000000761' const selectedRuntimes = { getRuntime: vi.fn(async () => selectedRuntime) } const harness = createHarness( { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run: vi.fn() }, undefined, 'always', undefined, false, selectedRuntimes ) harness.assistantDatabase.getProject.mockReturnValue({ id: projectId, rootPath: '/srv/project', executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000762', remoteRootPath: '/srv/project' } }) return { harness, projectId, selectedRuntimes } } const managedSshRequest = ( projectId: string, requestId: string, conversationId: string ) => ({ requestId, conversationId, projectId, runtimeSelection: { provider: 'opencode' as const }, prompt: 'continue on Agent', workMode: 'execute' as const, knowledgeLibraryIds: [] }) it('binds the production image service before text dispatch and routes regenerate, Ask rejection, and deletion through IPC', async () => { const database = new AssistantDatabase(':memory:') database.initialize(process.cwd()) const conversationId = crypto.randomUUID() const userId = crypto.randomUUID() const messageId = crypto.randomUUID() const header = { id: conversationId, title: 'Image IPC', updatedAt: Date.now(), workMode: 'execute' as const } database.saveLocalConversations([{ header, messages: [] }]) const profile = { id: crypto.randomUUID(), name: 'Image model', modelName: 'image-test', protocol: 'openai-images-generations', baseUrl: 'https://image.test/v1', authentication: 'none' as const, allowConversationInvocation: true } const fetcher = vi.fn(async () => Response.json({ data: [{ b64_json: 'iVBORw0KGgo=' }] })) const service = new ImageGenerationService({ database, getSettings: async () => ({ modelProfiles: [profile], defaultImageModelProfileId: profile.id }), fetcher }) service.initialize() const run = vi.fn(async function* (request: AgentExecutionRequest) { expect(database.getConversation(conversationId).messages.map(message => message.id)).toEqual([userId, messageId]) expect(request.imageToolBinding?.context.messageId).toBe(messageId) expect(await request.imageToolBinding?.describe()).toContain(profile.id) expect((await request.imageToolBinding!.call({ intent: 'create', prompt: 'A blue circle' }, 'image-call')).state).toBe('completed') yield { type: 'done' as const, requestId: request.requestId } }) const harness = createHarness({ runtimeId: 'model', capability: 'text', supportsToolExecution: true, run }, undefined, 'always', undefined, false, undefined, undefined, undefined, false, undefined, undefined, undefined, service, database) const event = trustedEvent(harness.webContents) try { const requestId = crypto.randomUUID() await harness.handler!(event, { requestId, conversationId, prompt: 'Draw a circle', workMode: 'execute', currentUserMessageId: userId, currentAssistantMessageId: messageId }) await vi.waitFor(() => expect(database.getTask(requestId).status).toBe('completed')) const original = database.getConversation(conversationId).messages[1]!.imageOperations![0]! expect(database.getArtifact(original.artifactIds[0]!).content).toContain('iVBORw0KGgo=') const regenerate = electronMocks.handlers.get(ipcChannels.imageOperationRegenerate)! const cancel = electronMocks.handlers.get(ipcChannels.imageOperationCancel)! expect(() => cancel(event, { conversationId, operationId: original.id, prompt: 'injected' })).toThrow() const regenerated = await regenerate(event, { conversationId, operationId: original.id }) as typeof original expect(regenerated.id).not.toBe(original.id) expect(regenerated.messageId).toBe(messageId) // 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) database.saveLocalConversations([{ header: { ...header, workMode: 'ask' }, messages: [] }]) await expect(regenerate(event, { conversationId, operationId: original.id })).rejects.toThrow('Execute') expect(fetcher).toHaveBeenCalledTimes(2) const cancelConversation = vi.spyOn(service, 'cancelConversation') await electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId) expect(cancelConversation).toHaveBeenCalledWith(conversationId) expect(() => database.getConversation(conversationId)).toThrow() } finally { await harness.dispose() await service.dispose() database.close() } }) it('persists uploads before dispatch while withholding image bytes from a nonvisual text model', async () => { const database = new AssistantDatabase(':memory:') database.initialize(process.cwd()) const conversationId = crypto.randomUUID() const userId = crypto.randomUUID() database.saveLocalConversations([{ header: { id: conversationId, title: 'Upload', updatedAt: Date.now() }, messages: [] }]) const service = new ImageGenerationService({ database, getSettings: async () => ({ modelProfiles: [] }) }) const run = vi.fn(async function* (request: AgentExecutionRequest) { expect(request.images).toBeUndefined() expect(request.trustedInstructions).toContain('cannot view') const sources = database.getConversation(conversationId).messages.find(message => message.id === userId)!.imageSourceArtifactIds! expect(sources).toHaveLength(1) expect(database.getArtifact(sources[0]!).content).toContain('iVBORw0KGgo=') yield { type: 'done' as const, requestId: request.requestId } }) const harness = createHarness({ runtimeId: 'model', capability: 'text', supportsToolExecution: true, run }, undefined, 'always', undefined, false, undefined, undefined, undefined, false, undefined, undefined, undefined, service, database) harness.contextManager.enrichRequest.mockImplementation(request => ({ ...request, images: [{ name: 'Upload', mediaType: 'image/png', data: 'iVBORw0KGgo=' }] })) try { const requestId = crypto.randomUUID() await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId, prompt: 'Turn it red', workMode: 'execute', currentUserMessageId: userId, currentAssistantMessageId: crypto.randomUUID() }) await vi.waitFor(() => expect(database.getTask(requestId).status).toBe('completed')) expect(run).toHaveBeenCalledOnce() } finally { await harness.dispose(); await service.dispose(); database.close() } }) it('restores image context at the IPC boundary and persists the quiet notice on its artifact event', async () => { const png = 'iVBORw0KGgo=' const artifactId = '00000000-0000-4000-8000-000000000301' const run = vi.fn(async function* (request: { requestId: string images?: Array<{ data: string }> }) { expect(request.images?.[0]?.data).toBe(png) yield { requestId: request.requestId, type: 'generated-image', mimeType: 'image/png', data: png, title: '编辑图片', imageContextNotice: 'editing-unavailable' } yield { requestId: request.requestId, type: 'done' } }) const harness = createHarness({ runtimeId: 'model', capability: 'image-generation', supportsToolExecution: false, run }) harness.assistantDatabase.getArtifact.mockReturnValue({ id: artifactId, kind: 'image', content: `data:image/png;base64,${png}` }) try { await harness.handler!(trustedEvent(harness.webContents), { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: '把圆改成蓝色', imageContextArtifactIds: [artifactId], workMode: 'ask' }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus) .toHaveBeenCalledWith(expect.any(String), 'completed')) expect(harness.assistantDatabase.getArtifact).toHaveBeenCalledWith(artifactId) expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ type: 'artifact', imageContextNotice: 'editing-unavailable' }) ) } finally { await harness.dispose() } }) it('defaults custom scheduled Tasks to Execute', async () => { const harness = createHarness({ runtimeId: 'continue', capability: 'chat', supportsToolExecution: true, async *run() { yield { type: 'done', requestId: 'unused' } as const } }) const conversationId = '00000000-0000-4000-8000-000000000705' const createInput = { projectId: '00000000-0000-4000-8000-000000000401', conversationId, title: '每周汇总', prompt: '汇总本周进展', recurrence: 'weekly' as const, nextRunAt: new Date(Date.now() + 60_000).toISOString() } await electronMocks.handlers.get( ipcChannels.schedulesCreate )?.(trustedEvent(harness.webContents), createInput) expect( harness.assistantDatabase.createSchedule ).toHaveBeenCalledWith({ ...createInput, workMode: 'execute' }) await harness.dispose() }) it.each([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, 'always', 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(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, 'always', 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(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, 'always', 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(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, 'always', 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, 'always', 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, 'always', 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(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, 'always', 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(resolve => { entered = resolve }) const cleanupStarted = new Promise(resolve => { cleaning = resolve }) const cleanup = new Promise(resolve => { finish = resolve }) const run = vi.fn(async function* (_input: AgentExecutionRequest, signal: AbortSignal) { entered() await new Promise(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, 'always', 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(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(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, 'always', 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(resolve => { entered = resolve }) const run = vi.fn(async function* (input: AgentExecutionRequest, signal: AbortSignal) { if (++calls === 2) { entered() await new Promise(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, 'always', 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() expect(() => electronMocks.handlers.get(ipcChannels.supervisionBatches)!(event, { ...input, limit: 21 })).toThrow() } finally { await harness.dispose(); database.close() } }) 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, 'always', 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.each(['global', 'projects'] as const)('collects supervision within the exact UI timeRange before limits (%s)', async (kind) => { vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(new Date('2026-09-22T08:00:00.000Z')) const database = new AssistantDatabase(':memory:') database.initialize(process.cwd()) const project = database.createProject({ name: 'Review', description: '', rootPath: process.cwd(), defaultWorkMode: 'ask' }) const conversationId = crypto.randomUUID() const from = '2026-09-22T10:00:00.000Z' const to = '2026-09-22T10:15:00.000Z' const after = '2026-09-22T10:15:00.001Z' const oldTask = database.createTask({ id: crypto.randomUUID(), projectId: project.id, title: 'Old completed task', instructions: 'Review', workMode: 'ask' }) const memory = database.createMemory({ scope: 'global', type: 'fact', content: 'Current background content' }) vi.setSystemTime(new Date(from)) const newTask = database.createTask({ id: crypto.randomUUID(), projectId: project.id, title: 'Created in range', instructions: 'Review', workMode: 'ask' }) vi.setSystemTime(new Date(to)) database.updateTaskStatus(oldTask.id, 'completed') vi.setSystemTime(new Date(after)) database.updateTaskStatus(newTask.id, 'completed') database.createTask({ id: crypto.randomUUID(), projectId: project.id, title: 'Future task', instructions: 'Review', workMode: 'ask' }) database.setMemoryStatus(memory.id, 'confirmed') const message = (content: string, timestamp: string) => ({ id: crypto.randomUUID(), role: 'user' as const, state: 'complete' as const, content, createdAt: Date.parse(timestamp) }) database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Review', updatedAt: Date.parse(after) }, messages: [message('Before range', '2026-09-22T09:59:59.999Z'), message('At start', from), message('At end', to), ...Array.from({ length: 25 }, () => message('After range', after))] }, ...Array.from({ length: 21 }, () => ({ header: { id: crypto.randomUUID(), projectId: project.id, title: 'Future-only conversation', updatedAt: Date.parse(after) + 1 }, messages: [message('Future-only message', after)] }))]) const run = vi.fn(async function* (request: AgentExecutionRequest) { yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify({ summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) } yield { type: 'done' as const, requestId: request.requestId } }) vi.setSystemTime(new Date('2026-09-23T12:00:00.000Z')) const harness = createHarness({ runtimeId: 'model', capability: 'chat', run }, undefined, 'always', undefined, false, undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database) try { const scope = kind === 'global' ? { kind } : { kind, projectIds: [project.id] } const input = { trigger: 'manual', scope, timeRange: { from, to } } 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() expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id })).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(() => electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).toThrow('不匹配') expect(() => electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!(trustedEvent(harness.webContents), { resultId: 'wrong-result', sourceId: overview[0]!.sourceId })).toThrow('不匹配') const legacy = database.buildHeartbeatInput({ scope, lookbackHours: 1 }, new Date(to)) expect(legacy.tasks.some(task => task.id === oldTask.id)).toBe(false) expect(legacy.tasks.some(task => task.title === 'Future task')).toBe(true) database.setProjectArchived(project.id, true) await expect(electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), { ...input, scope: { kind: 'projects', projectIds: [project.id] } })).rejects.toThrow('must exist and be active') expect(run).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 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 (first) { first = false if (ending === 'incomplete') return if (ending === 'provider') throw new Error('Provider HTTP 429: retry later') if (ending !== 'cleanup') await new Promise(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, 'always', 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).toHaveBeenCalledOnce() await invoke() expect(database.listSupervisionResults()).toHaveLength(1) expect(releaseConversation).toHaveBeenCalledTimes(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, 'always', 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 = 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(), workMode: 'ask', runtimeSelection: { provider: 'opencode' } }, messages: [] }]) const harness = createHarness({ capability: 'chat' }, undefined, 'always', undefined, false, undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database) try { await electronMocks.handlers.get(ipcChannels.supervisionContinue)!(trustedEvent(harness.webContents), { conversationId, prompt: 'Continue this review' }) await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.conversationQueueDispatch, expect.objectContaining({ input: expect.objectContaining({ conversationId, prompt: 'Continue this review', attachments: [] }) }))) const dispatch = harness.webContents.send.mock.calls.find(([channel]) => channel === ipcChannels.conversationQueueDispatch)![1] as ConversationQueueDispatch expect(database.isConversationUserQueueItemDispatching(dispatch.item.id)).toBe(true) expect(harness.contextManager.restoreFromQueue).not.toHaveBeenCalled() } finally { await harness.dispose(); database.close() } }) it('validates supervision knowledge preview and commit against actual local entity ownership', async () => { const database = new KnowledgeDatabase(':memory:') database.initialize() const library = database.createKnowledgeBase({ name: 'Target', storageMode: 'reference' }) const other = database.createKnowledgeBase({ name: 'Other', storageMode: 'reference' }) const entity = database.createEntity({ knowledgeBaseId: other.id, name: 'Original', type: 'Concept' }) const harness = createHarness({ capability: 'chat' }, undefined, 'always', undefined, false, undefined, { database }) Object.assign(harness.assistantDatabase, { getSupervisionSource: () => ({ id: 'source', title: 'Source', content: 'Evidence' }) }) const event = trustedEvent(harness.webContents) const preview = electronMocks.handlers.get(ipcChannels.supervisionKnowledgePreview)! const commit = electronMocks.handlers.get(ipcChannels.supervisionKnowledgeCommit)! const input = { operation: 'update-entity', libraryId: library.id, entityId: entity.id, label: 'Revised', type: 'Concept', description: 'Preview description', aliases: ['Alias'], sourceId: 'source' } try { expect(() => preview(event, input)).toThrow('不属于所选知识库') expect(() => preview(event, { ...input, entityId: crypto.randomUUID() })).toThrow('不属于所选知识库') expect(() => preview(event, { ...input, operation: 'create-entity', libraryId: crypto.randomUUID() })).toThrow('知识库不存在') const binding = vi.spyOn(database.externalStore, 'hasBinding').mockReturnValue(true) expect(() => preview(event, { ...input, operation: 'create-entity' })).toThrow('EXTERNAL_KB_READ_ONLY') binding.mockReturnValue(false) const valid = { ...input, libraryId: other.id } const first = await preview(event, valid) as { previewId: string } expect(first).toMatchObject({ entity: valid, source: { content: 'Evidence' } }) binding.mockReturnValue(true) expect(() => commit(event, { previewId: first.previewId })).toThrow('EXTERNAL_KB_READ_ONLY') expect(database.getEntity(entity.id)?.name).toBe('Original') binding.mockReturnValue(false) const second = await preview(event, valid) as { previewId: string } const getEntity = vi.spyOn(database, 'getEntity').mockReturnValue({ ...entity, knowledgeBaseId: library.id }) expect(() => commit(event, { previewId: second.previewId })).toThrow('不属于所选知识库') getEntity.mockRestore() const third = await preview(event, valid) as { previewId: string } await commit(event, { previewId: third.previewId }) expect(database.getEntity(entity.id)).toMatchObject({ name: 'Revised', description: 'Preview description', locked: true }) const create = await preview(event, { ...input, operation: 'create-entity', entityId: undefined }) as { previewId: string } await commit(event, { previewId: create.previewId }) expect(database.listEntities(library.id)).toEqual([expect.objectContaining({ name: 'Revised', locked: true })]) } finally { vi.restoreAllMocks(); await harness.dispose(); database.close() } }) it.each(['completed', 'failed'] as const)('creates an immediate task into the production queue and dispatches exactly one ordinary run (%s)', async (status) => { const database = new AssistantDatabase(':memory:') database.initialize(process.cwd()) const conversationId = crypto.randomUUID() database.saveLocalConversations([{ header: { id: conversationId, title: 'Immediate', updatedAt: Date.now(), workMode: 'ask' }, messages: [] }]) const run = vi.fn(async function* (request: AgentExecutionRequest) { if (status === 'failed') { yield { type: 'error' as const, requestId: request.requestId, message: 'Runtime failed' } return } yield { type: 'text' as const, requestId: request.requestId, delta: 'Once' } yield { type: 'done' as const, requestId: request.requestId } }) const harness = createHarness( { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run }, undefined, 'always', undefined, false, undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database ) const event = trustedEvent(harness.webContents) try { await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId) const create = electronMocks.handlers.get(ipcChannels.schedulesCreate)! const input = { conversationId, title: 'Immediate', prompt: 'Run once', recurrence: 'once', nextRunAt: '2020-01-01T00:00:00.000Z' } expect(() => create(event, input)).toThrow('首次运行时间必须晚于当前时间') expect(database.listSchedules()).toEqual([]) const schedule = await create(event, { ...input, runImmediately: true }) as AssistantSchedule expect(schedule.enabled).toBe(false) const dispatches = () => harness.webContents.send.mock.calls.filter( ([channel]) => channel === ipcChannels.conversationQueueDispatch ) expect(dispatches()).toHaveLength(1) const dispatch = dispatches()[0]![1] as ConversationQueueDispatch expect(dispatch).toMatchObject({ scheduled: true, item: { taskId: schedule.taskId } }) expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([]) await harness.handler!(event, { requestId: dispatch.item.id, conversationId, queueItemId: dispatch.item.id, prompt: input.prompt, workMode: 'ask', currentUserMessageId: crypto.randomUUID(), currentAssistantMessageId: crypto.randomUUID() }) await vi.waitFor(() => expect(database.getTask(schedule.taskId).status).toBe(status)) await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId) expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([]) expect(dispatches()).toHaveLength(1) expect(run).toHaveBeenCalledOnce() expect(database.listConversationQueueItems()).toEqual([]) expect(database.listSchedules()).toHaveLength(1) } finally { await harness.dispose() database.close() } }) it('dispatches scheduled messages through ordinary agent runs with current history and tools', async () => { const fullOutput = `${'x'.repeat(1_000_001)} scheduled tail` const taskId = '00000000-0000-4000-8000-000000000701' const conversationId = '00000000-0000-4000-8000-000000000702' const scheduleId = '00000000-0000-4000-8000-000000000703' const runId = '00000000-0000-4000-8000-000000000704' const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run( request: { requestId: string; history?: unknown; workMode?: string }, _signal: AbortSignal, authorize: ( input: { scopeKey: string title: string description: string } ) => Promise ) { expect(request.requestId).toBe(runId) expect(request.history).toEqual([{ role: 'user', content: 'Earlier conversation' }]) expect(request.workMode).toBe('execute') expect(await authorize({ scopeKey: 'workspace.write', title: '写入工作区', description: '更新状态文件' })).toBe('once') yield { requestId: request.requestId, type: 'text', delta: fullOutput } as const yield { requestId: request.requestId, type: 'done' } as const } } const harness = createHarness(runtime) await electronMocks.handlers.get(ipcChannels.conversationQueueReady)?.( trustedEvent(harness.webContents), conversationId) const schedule = { id: scheduleId, projectId: '00000000-0000-4000-8000-000000000401', taskId, conversationId, title: '每日状态', prompt: '汇总状态', workMode: 'execute' as const, recurrence: 'daily' as const, nextRunAt: '2026-08-20T00:00:00.000Z', enabled: true, createdAt: '2026-08-19T00:00:00.000Z', updatedAt: '2026-08-19T00:00:00.000Z' } const queueItem = { id: runId, conversationId, source: 'schedule' as const, label: schedule.title, scheduleRunId: runId, scheduleId, taskId, createdAt: '2026-08-19T00:01:00.000Z' } harness.assistantDatabase.queueScheduleNow.mockReturnValue( queueItem ) harness.assistantDatabase.getConversationQueueItem.mockReturnValue(queueItem) harness.assistantDatabase.listConversationQueueItems .mockReturnValueOnce([queueItem]) .mockReturnValue([]) harness.assistantDatabase.claimConversationQueueItem.mockReturnValue({ source: 'schedule', item: queueItem, schedule, runId }) harness.approvalBroker.request.mockResolvedValue('once') await electronMocks.handlers.get( ipcChannels.schedulesRunNow )?.(trustedEvent(harness.webContents), scheduleId) expect(harness.assistantDatabase.createTask).not.toHaveBeenCalled() expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.conversationQueueDispatch, { item: queueItem, scheduled: true, input: { conversationId, projectId: schedule.projectId, prompt: schedule.prompt } }) await harness.handler?.(trustedEvent(harness.webContents), { requestId: runId, conversationId, queueItemId: queueItem.id, prompt: schedule.prompt, workMode: 'execute', history: [{ role: 'user', content: 'Earlier conversation' }] }) await vi.waitFor(() => expect( harness.assistantDatabase.completeTaskScheduleRun ).toHaveBeenCalledWith(runId) ) expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(taskId, 'running') expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect( harness.assistantDatabase.appendConversationMessage ).not.toHaveBeenCalled() expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ requestId: runId, type: 'text', delta: fullOutput })) expect( harness.assistantDatabase.createTextArtifact ).not.toHaveBeenCalled() expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.conversationsChanged ) await harness.dispose() }) it('allows scheduled messages to ask questions through the ordinary conversation UI', async () => { const taskId = '00000000-0000-4000-8000-000000000711' const conversationId = '00000000-0000-4000-8000-000000000712' const scheduleId = '00000000-0000-4000-8000-000000000713' const runId = '00000000-0000-4000-8000-000000000714' const respondToQuestion = vi.fn().mockResolvedValue(undefined) const runtime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, respondToQuestion, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'question', questionId: 'opencode-background-question', questions: [ { header: 'Choice', question: 'Choose an implementation', options: [], multiple: false, custom: true } ] } as const yield { requestId: request.requestId, type: 'done' } as const } } const harness = createHarness(runtime) await electronMocks.handlers.get(ipcChannels.conversationQueueReady)?.( trustedEvent(harness.webContents), conversationId) const schedule = { id: scheduleId, projectId: '00000000-0000-4000-8000-000000000401', taskId, conversationId, title: '后台提问测试', prompt: '执行任务', workMode: 'execute' as const, recurrence: 'daily' as const, nextRunAt: '2026-08-20T00:00:00.000Z', enabled: true, createdAt: '2026-08-19T00:00:00.000Z', updatedAt: '2026-08-19T00:00:00.000Z' } const queueItem = { id: runId, conversationId, source: 'schedule' as const, label: schedule.title, scheduleRunId: runId, scheduleId, taskId, createdAt: '2026-08-19T00:01:00.000Z' } harness.assistantDatabase.queueScheduleNow.mockReturnValue( queueItem ) harness.assistantDatabase.getConversationQueueItem.mockReturnValue(queueItem) harness.assistantDatabase.listConversationQueueItems .mockReturnValueOnce([queueItem]) .mockReturnValue([]) harness.assistantDatabase.claimConversationQueueItem.mockReturnValue({ source: 'schedule', item: queueItem, schedule, runId }) await electronMocks.handlers.get( ipcChannels.schedulesRunNow )?.(trustedEvent(harness.webContents), scheduleId) await harness.handler?.(trustedEvent(harness.webContents), { requestId: runId, conversationId, queueItemId: queueItem.id, prompt: schedule.prompt, workMode: 'execute' }) await vi.waitFor(() => expect( harness.assistantDatabase.completeTaskScheduleRun ).toHaveBeenCalledWith(runId) ) expect(respondToQuestion).not.toHaveBeenCalled() expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ type: 'question', questionId: 'opencode-background-question' })) await harness.dispose() }) it('serializes Agent runs that target the same Conversation', async () => { let finishRun: (() => void) | undefined const runtimeStarted = vi.fn() const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: false, async *run(request: { requestId: string }) { runtimeStarted(request.requestId) await new Promise((resolve) => { finishRun = resolve }) yield { requestId: request.requestId, type: 'done' } as const } } const harness = createHarness(runtime) const conversationId = '00000000-0000-4000-8000-000000000721' const firstRun = harness.handler?.( trustedEvent(harness.webContents), { requestId: '00000000-0000-4000-8000-000000000722', conversationId, prompt: '第一条', workMode: 'ask', knowledgeLibraryIds: [] } ) await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled()) await expect( harness.handler?.(trustedEvent(harness.webContents), { requestId: '00000000-0000-4000-8000-000000000723', conversationId, prompt: '第二条', workMode: 'ask', knowledgeLibraryIds: [] }) ).rejects.toThrow('当前对话已有执行中的请求') finishRun?.() await firstRun await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith( '00000000-0000-4000-8000-000000000722', 'completed' ) ) await expect( harness.handler?.(trustedEvent(harness.webContents), { requestId: '00000000-0000-4000-8000-000000000724', conversationId, prompt: '第三条', workMode: 'ask', knowledgeLibraryIds: [] }) ).resolves.toBeUndefined() await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalledTimes(2) ) finishRun?.() await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith( '00000000-0000-4000-8000-000000000724', 'completed' ) ) await harness.dispose() }) it('keeps the active lease through settings reload and releases it only after cancellation settles', async () => { const controls = new Map< string, { finish: () => void signal: AbortSignal } >() const runtimeStarted = vi.fn() const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: false, async *run( request: { conversationId: string; requestId: string }, signal: AbortSignal ) { runtimeStarted(request.conversationId) await new Promise((resolve) => { controls.set(request.conversationId, { finish: resolve, signal }) }) signal.throwIfAborted() yield { requestId: request.requestId, type: 'done' } as const } } const goodbuddyConfigService = { takePendingReload: vi.fn(() => 'none'), revokeRequest: vi.fn(), clear: vi.fn() } const knowledgeGateway = { grant: vi.fn(() => 'lease-race-capability'), getAvailableToolNames: vi.fn(() => []), drainReferences: vi.fn(() => []), revoke: vi.fn() } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, false, goodbuddyConfigService ) const requestId = '00000000-0000-4000-8000-000000000741' const oldConversationId = '00000000-0000-4000-8000-000000000742' const newConversationId = '00000000-0000-4000-8000-000000000743' const event = trustedEvent(harness.webContents) await harness.handler?.(event, { requestId, conversationId: oldConversationId, prompt: '旧请求', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(controls.has(oldConversationId)).toBe(true) ) await electronMocks.handlers.get( ipcChannels.runtimeCustomizationUpdate )?.(event, { opencode: {}, continue: { presets: [] } }) expect(controls.get(oldConversationId)?.signal.aborted).toBe(false) await expect(harness.handler?.(event, { requestId, conversationId: newConversationId, prompt: '替换请求', workMode: 'ask', knowledgeLibraryIds: [] })).rejects.toThrow('请求正在执行') harness.cancelHandler?.(event, requestId) expect(controls.get(oldConversationId)?.signal.aborted).toBe(true) controls.get(oldConversationId)?.finish() await vi.waitFor(() => expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce() ) expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消') await harness.handler?.(event, { requestId, conversationId: newConversationId, prompt: '替换请求', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(controls.has(newConversationId)).toBe(true) ) harness.cancelHandler?.(event, requestId) expect(controls.get(newConversationId)?.signal.aborted).toBe(true) await expect( harness.handler?.(event, { requestId: '00000000-0000-4000-8000-000000000744', conversationId: newConversationId, prompt: '不能并发', workMode: 'ask', knowledgeLibraryIds: [] }) ).rejects.toThrow('当前对话已有执行中的请求') controls.get(newConversationId)?.finish() await vi.waitFor(() => { const cancellations = harness.assistantDatabase.updateTaskStatus.mock.calls.filter( ([id, status]) => id === requestId && status === 'cancelled' ) expect(cancellations).toHaveLength(2) }) await vi.waitFor(() => expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledTimes(2) ) await harness.handler?.(event, { requestId, conversationId: newConversationId, prompt: '清理后重试', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalledTimes(3) ) controls.get(newConversationId)?.finish() await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) await harness.dispose() }) it('cancels only an expert-team parent on settings replacement without synthesizing on the replacement model', async () => { let rejectExpert!: (error: Error) => void let parentSignal: AbortSignal | undefined const subagents = { run: vi.fn(async (input: { expert: { name: string }; signal: AbortSignal }) => { parentSignal = input.signal if (input.expert.name === 'First') return { childTaskId: 'first', output: 'Finished analysis' } return new Promise((_, reject) => { rejectExpert = reject }) }), synthesize: vi.fn(async () => 'Must not use replacement model'), dispose: vi.fn(async () => undefined) } let finishPeer!: () => void let peerSignal: AbortSignal | undefined const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }, signal: AbortSignal) { peerSignal = signal await new Promise(resolve => { finishPeer = resolve }) yield { requestId: request.requestId, type: 'done' as const } } } const harness = createHarness(runtime, undefined, 'always', subagents) harness.assistantDatabase.listExperts.mockReturnValue([ { id: '00000000-0000-4000-8000-000000000781', name: 'First', enabled: true }, { id: '00000000-0000-4000-8000-000000000782', name: 'Second', enabled: true } ]) harness.onRuntimeSettingsChanged.mockImplementation(async () => { rejectExpert(new Error('默认模型设置已更改')) }) const event = trustedEvent(harness.webContents) const teamId = '00000000-0000-4000-8000-000000000783' try { await harness.handler?.(event, { requestId: teamId, conversationId: 'expert-team', prompt: 'Analyze with the team', teamMode: true, workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(subagents.run).toHaveBeenCalledTimes(2)) await harness.handler?.(event, { requestId: '00000000-0000-4000-8000-000000000784', conversationId: 'ordinary-peer', prompt: 'Keep working', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(peerSignal).toBeDefined()) await electronMocks.handlers.get(ipcChannels.runtimeCustomizationUpdate)!(event, { opencode: {}, continue: { presets: [] } }) expect(parentSignal?.aborted).toBe(true) expect(peerSignal?.aborted).toBe(false) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus) .toHaveBeenCalledWith(teamId, 'cancelled', '请求已取消')) expect(subagents.synthesize).not.toHaveBeenCalled() } finally { rejectExpert?.(new Error('Test cleanup')) finishPeer?.() await harness.dispose() } }) it('detaches an accepted managed SSH run without aborting it or waiting for completion', async () => { let finishRun = (): void => undefined let remoteSignal: AbortSignal | undefined let statusConsumed = false const remoteRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, async *run( request: { requestId: string }, signal: AbortSignal ) { remoteSignal = signal yield { requestId: request.requestId, type: 'status', message: '远端 ACP Runtime 正在处理请求' } as const statusConsumed = true await new Promise((resolve) => { finishRun = resolve }) signal.throwIfAborted() yield { requestId: request.requestId, type: 'done' } as const } } const selectedRuntimes = { getRuntime: vi.fn(async () => remoteRuntime) } const harness = createHarness( remoteRuntime, undefined, 'always', undefined, false, selectedRuntimes ) const projectId = '00000000-0000-4000-8000-000000000751' harness.assistantDatabase.getProject.mockReturnValue({ id: projectId, rootPath: '/srv/project', executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000752', remoteRootPath: '/srv/project' } }) const requestId = '00000000-0000-4000-8000-000000000753' const run = harness.handler?.( trustedEvent(harness.webContents), { requestId, conversationId: 'managed-ssh-conversation', projectId, runtimeSelection: { provider: 'opencode' }, prompt: 'continue on Agent', workMode: 'execute', knowledgeLibraryIds: [] } ) await run await vi.waitFor(() => expect(statusConsumed).toBe(true)) const disposal = harness.dispose() await expect(disposal).resolves.toBeUndefined() expect(remoteSignal?.aborted).toBe(false) expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消') finishRun() await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) }) it('persists remote usage with the inherited project Runtime without pinning the conversation', async () => { const requestId = '00000000-0000-4000-8000-000000000790' const conversationId = 'inherited-remote-usage' const selection = { provider: 'opencode', profileId: '00000000-0000-4000-8000-000000000791' } as const const provenance = { source: 'remote-semantic-transcript', bindingId: 'inherited-usage', operationId: requestId, semanticSequence: '1', eventIndex: 0 } as const const selectedRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'context-metrics', contextTokens: 42, effectiveTriggerTokens: 1000, compressionEnabled: false, source: 'provider', remoteProvenance: provenance } as const yield { requestId: request.requestId, type: 'done', remoteProvenance: { ...provenance, semanticSequence: '2' } } as const } } const { harness, projectId } = createManagedSshHarness(selectedRuntime) 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 read-only workspace and delegation tools in the production Ask instruction', async () => { const received: string[] = [] const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string; trustedInstructions?: string }) { received.push(request.trustedInstructions ?? '') yield { type: 'done', requestId: request.requestId } as const } } const harness = createHarness(runtime) const requestId = '00000000-0000-4000-8000-000000000794' await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'ask-workspace', prompt: 'Read the source', workMode: 'ask' }) await vi.waitFor(() => expect(received).toHaveLength(1)) for (const name of ['workspace_rg', 'workspace_read_text', 'output_read', 'subagent_delegate']) { expect(received[0]).toContain(name) } expect(received[0]).not.toContain('workspace_apply_patch') expect(received[0]).not.toContain('process_execute') await harness.dispose() }) it('durably commits remote text before resume, deduplicates it, and preserves its semantic payload', async () => { const requestId = '00000000-0000-4000-8000-000000000763' const provenance = { source: 'remote-semantic-transcript' as const, bindingId: 'binding-semantic-text', operationId: 'operation-semantic-text', semanticSequence: '1', eventIndex: 0 } const taggedDelta = 'remote reasoningremote answer' const resumed = vi.fn() const selectedRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { const remoteText = { requestId: request.requestId, type: 'text', delta: taggedDelta, remoteProvenance: provenance } as const yield remoteText expect( harness.assistantDatabase.appendRemoteTaskEventOnce ).toHaveBeenCalledWith({ taskId: request.requestId, bindingId: provenance.bindingId, operationId: provenance.operationId, semanticSequence: provenance.semanticSequence, eventIndex: provenance.eventIndex, kind: 'text', payload: { requestId: request.requestId, type: 'text', delta: taggedDelta } }) resumed('first') yield remoteText resumed('duplicate') yield { requestId: request.requestId, type: 'done', remoteProvenance: { ...provenance, semanticSequence: '2' } } as const } } const fixture = createManagedSshHarness(selectedRuntime) const { harness } = fixture harness.assistantDatabase.appendRemoteTaskEventOnce .mockReturnValueOnce(true) .mockReturnValueOnce(false) .mockReturnValueOnce(true) await harness.handler?.( trustedEvent(harness.webContents), managedSshRequest( fixture.projectId, requestId, 'managed-ssh-semantic-text' ) ) await vi.waitFor(() => expect( harness?.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) expect(resumed.mock.calls).toEqual([['first'], ['duplicate']]) const persistedTextCalls = harness.assistantDatabase.appendRemoteTaskEventOnce.mock.calls.filter( ([input]) => input.kind === 'text' ) expect(persistedTextCalls).toHaveLength(2) const publicEvents = harness.webContents.send.mock.calls .filter(([channel]) => channel === ipcChannels.agentEvent) .map(([, event]) => event) .filter((event) => event.requestId === requestId) expect(publicEvents).toEqual([ { requestId, type: 'text', delta: taggedDelta }, { requestId, type: 'done' } ]) expect( publicEvents.some((event) => event.type === 'reasoning') ).toBe(false) expect( publicEvents.some((event) => 'remoteProvenance' in event) ).toBe(false) await harness.dispose() }) it('persists a remote semantic checkpoint without publishing it', async () => { const requestId = '00000000-0000-4000-8000-000000000764' const provenance = { source: 'remote-semantic-transcript' as const, bindingId: 'binding-semantic-checkpoint', operationId: 'operation-semantic-checkpoint', semanticSequence: '1', eventIndex: 0 } const resumedAfterCheckpoint = vi.fn() const selectedRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'remote-semantic-checkpoint', remoteProvenance: provenance } as const expect( harness.assistantDatabase.appendRemoteTaskEventOnce ).toHaveBeenCalledWith({ taskId: request.requestId, bindingId: provenance.bindingId, operationId: provenance.operationId, semanticSequence: provenance.semanticSequence, eventIndex: provenance.eventIndex, kind: 'remote-semantic-checkpoint', payload: { requestId: request.requestId, type: 'remote-semantic-checkpoint' } }) resumedAfterCheckpoint() yield { requestId: request.requestId, type: 'done', remoteProvenance: { ...provenance, semanticSequence: '2' } } as const } } const fixture = createManagedSshHarness(selectedRuntime) const { harness } = fixture await harness.handler?.( trustedEvent(harness.webContents), managedSshRequest( fixture.projectId, requestId, 'managed-ssh-semantic-checkpoint' ) ) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) expect(resumedAfterCheckpoint).toHaveBeenCalledOnce() expect(harness.webContents.send).not.toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ requestId, type: 'remote-semantic-checkpoint' }) ) await harness.dispose() }) it('persists remote done before resuming the generator once for Agent acknowledgement', async () => { const requestId = '00000000-0000-4000-8000-000000000765' const lifecycle: string[] = [] const provenance = { source: 'remote-semantic-transcript' as const, bindingId: 'binding-semantic-done', operationId: 'operation-semantic-done', semanticSequence: '1', eventIndex: 0 } const resumedAfterDone = vi.fn(() => { lifecycle.push('resume-done') }) const selectedRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done', remoteProvenance: provenance } as const resumedAfterDone() } } const { harness, projectId } = createManagedSshHarness(selectedRuntime) harness.assistantDatabase.appendRemoteTaskEventOnce.mockImplementation( (input) => { lifecycle.push(`persist-${input.kind}`) return true } ) await harness.handler?.( trustedEvent(harness.webContents), managedSshRequest( projectId, requestId, 'managed-ssh-semantic-done' ) ) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) expect(lifecycle).toEqual(['persist-done', 'resume-done']) expect(resumedAfterDone).toHaveBeenCalledOnce() expect( harness.assistantDatabase.appendTaskEvent ).not.toHaveBeenCalledWith( requestId, 'done', expect.anything() ) await harness.dispose() }) it('persists and acknowledges a remote terminal error before failing without duplicate delivery', async () => { const requestId = '00000000-0000-4000-8000-000000000766' const errorMessage = 'remote Agent failed' const lifecycle: string[] = [] const provenance = { source: 'remote-semantic-transcript' as const, bindingId: 'binding-semantic-error', operationId: 'operation-semantic-error', semanticSequence: '1', eventIndex: 0 } const resumedAfterError = vi.fn(() => { expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith( requestId, 'failed', expect.any(String) ) lifecycle.push('resume-error') }) const selectedRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'error', status: 'failed', message: errorMessage, remoteProvenance: provenance } as const resumedAfterError() } } const fixture = createManagedSshHarness(selectedRuntime) const { harness } = fixture harness.assistantDatabase.appendRemoteTaskEventOnce.mockImplementation( (input) => { lifecycle.push(`persist-${input.kind}`) return true } ) await harness.handler?.( trustedEvent(harness.webContents), managedSshRequest( fixture.projectId, requestId, 'managed-ssh-semantic-error' ) ) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith( requestId, 'failed', expect.any(String) ) ) expect(lifecycle).toEqual(['persist-error', 'resume-error']) expect(resumedAfterError).toHaveBeenCalledOnce() expect( harness.assistantDatabase.appendTaskEvent ).not.toHaveBeenCalledWith( requestId, 'error', expect.anything() ) const publicErrors = harness.webContents.send.mock.calls .filter(([channel]) => channel === ipcChannels.agentEvent) .map(([, event]) => event) .filter( (event) => event.requestId === requestId && event.type === 'error' ) expect(publicErrors).toEqual([ { requestId, type: 'error', status: 'failed', message: errorMessage } ]) await harness.dispose() }) it.each([false, true])('reattaches from its committed cursor with tool recovery evidence %s', async (hasRecoveredResponse) => { const projectId = '00000000-0000-4000-8000-000000000781' const taskId = '00000000-0000-4000-8000-000000000782' const conversationId = '00000000-0000-4000-8000-000000000783' const userMessageId = '00000000-0000-4000-8000-000000000784' const assistantMessageId = '00000000-0000-4000-8000-000000000785' const consumed: string[] = [] const recoveredRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string remoteRecoveryOnly?: boolean remoteSemanticAfterSequence?: string remoteRecoveredTools?: unknown[] }) { expect(request).toMatchObject({ requestId: taskId, remoteRecoveryOnly: true, remoteSemanticAfterSequence: '2', remoteHasResponseTextAfterToolFailure: hasRecoveredResponse, remoteRecoveredTools: [ expect.objectContaining({ callId: 'tool-before-restart', name: 'Read', state: 'failed' }) ] }) yield { requestId: taskId, type: 'text', delta: 'recovered answer', remoteProvenance: { source: 'remote-semantic-transcript', bindingId: 'binding-recovery', operationId: taskId, semanticSequence: '3', eventIndex: 0 } } as const consumed.push('3') yield { requestId: taskId, type: 'remote-semantic-checkpoint', remoteProvenance: { source: 'remote-semantic-transcript', bindingId: 'binding-recovery', operationId: taskId, semanticSequence: '3', eventIndex: 1 } } as const if (hasRecoveredResponse) yield { requestId: taskId, type: 'tool', callId: 'tool-before-restart', name: 'Read', state: 'recoverable', summary: 'Handled missing file', error: 'missing file', remoteProvenance: { source: 'remote-semantic-transcript', bindingId: 'binding-recovery', operationId: taskId, semanticSequence: '4', eventIndex: 0 } } as const yield { requestId: taskId, type: 'done', sessionId: 'session-recovery', remoteProvenance: { source: 'remote-semantic-transcript', bindingId: 'binding-recovery', operationId: taskId, semanticSequence: '4', eventIndex: hasRecoveredResponse ? 1 : 0 } } as const consumed.push('4') yield { requestId: taskId, type: 'remote-semantic-checkpoint', remoteProvenance: { source: 'remote-semantic-transcript', bindingId: 'binding-recovery', operationId: taskId, semanticSequence: '4', eventIndex: hasRecoveredResponse ? 2 : 1 } } as const } } const { harness, selectedRuntimes } = createManagedSshHarness(recoveredRuntime) harness.assistantDatabase.getProject.mockReturnValue({ id: projectId, runtimeSelection: { provider: 'opencode' }, rootPath: '/srv/project', executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000786', remoteRootPath: '/srv/project' } }) harness.assistantDatabase.getConversation.mockReturnValue({ id: conversationId, projectId, runtimeSelection: { provider: 'opencode' }, messages: [ { id: assistantMessageId, role: 'assistant', content: '', state: 'streaming', createdAt: 1, tools: [ { callId: 'tool-before-restart', name: 'Read', state: 'failed', summary: 'Read failed', error: 'missing file' } ] } ] }) harness.assistantDatabase.listRecoverableRemoteTasks.mockReturnValue([ { taskId, projectId, conversationId, currentUserMessageId: userMessageId, currentAssistantMessageId: assistantMessageId, instructions: 'continue remotely', workMode: 'execute', status: 'running' } ]) harness.assistantDatabase.getHighestCommittedRemoteTaskEventSequenceForTask.mockReturnValue( '2' ) harness.assistantDatabase.hasRemoteResponseTextAfterToolFailure.mockReturnValue(hasRecoveredResponse) harness.recoveryGetHandler?.( trustedEvent(harness.webContents) ) await vi.waitFor(() => expect(consumed).toEqual(['3', '4'])) await vi.waitFor(() => expect( harness.assistantDatabase.appendRemoteConversationTaskEventOnce ).toHaveBeenCalledTimes(hasRecoveredResponse ? 5 : 4) ) expect( harness.assistantDatabase.appendRemoteConversationTaskEventOnce ).toHaveBeenNthCalledWith( 1, expect.objectContaining({ taskId, conversationId, assistantMessageId, semanticSequence: '3', event: { requestId: taskId, type: 'text', delta: 'recovered answer' } }) ) expect( harness.assistantDatabase.appendRemoteConversationTaskEventOnce ).toHaveBeenNthCalledWith( hasRecoveredResponse ? 4 : 3, expect.objectContaining({ taskId, semanticSequence: '4', event: hasRecoveredResponse ? { requestId: taskId, type: 'done', sessionId: 'session-recovery' } : { requestId: taskId, type: 'error', status: 'failed', message: 'Read 工具执行失败:missing file' } }) ) expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce() await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.remoteProjectRecoveryProgress, expect.objectContaining({ projectId, stage: 'completed' }) ) ) // The renderer converges on the persisted terminal state by re-reading // conversations, so the change notice must land before `completed`. const sendCalls = harness.webContents.send.mock.calls const completedIndex = sendCalls.findIndex( ([channel, payload]) => channel === ipcChannels.remoteProjectRecoveryProgress && (payload as { stage?: string }).stage === 'completed' ) expect(completedIndex).toBeGreaterThan(0) expect( sendCalls .slice(0, completedIndex) .some(([channel]) => channel === ipcChannels.conversationsChanged) ).toBe(true) await harness.dispose() }) it('replays an error committed before its checkpoint using remote tool states instead of terminal message states', async () => { const database = new AssistantDatabase(':memory:') database.initialize('C:\\Workspace') const project = database.createSshProject({ project: { name: 'Partial terminal', description: '', rootPath: '/srv/project', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } }, executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000850', remoteRootPath: '/srv/project' }, assertCurrent: () => {} }) const taskId = '00000000-0000-4000-8000-000000000851' const conversationId = '00000000-0000-4000-8000-000000000852' const assistantMessageId = '00000000-0000-4000-8000-000000000853' database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Partial terminal', updatedAt: 0 }, messages: [] }]) database.createTask({ id: taskId, projectId: project.id, conversationId, title: 'Partial terminal', instructions: 'Read', workMode: 'execute', remoteRecovery: { recoverable: true, currentUserMessageId: '00000000-0000-4000-8000-000000000854', currentAssistantMessageId: assistantMessageId } }) const append = (semanticSequence: string, eventIndex: number, event: Parameters[0]['event']) => database.appendRemoteConversationTaskEventOnce({ taskId, conversationId, assistantMessageId, bindingId: 'partial-terminal', operationId: taskId, semanticSequence, eventIndex, event }) const events: AgentEvent[] = [ { requestId: taskId, type: 'tool', callId: 'A', name: 'Read', state: 'failed', summary: 'Read', error: 'missing file' }, { requestId: taskId, type: 'text', delta: 'Handled missing file' }, { requestId: taskId, type: 'tool', callId: 'B', name: 'Write', state: 'running', summary: 'Write', input: 'preserve input' } ] events.forEach((event, index) => { append(String(index + 1), 0, event) append(String(index + 1), 1, { requestId: taskId, type: 'remote-semantic-checkpoint' }) }) const error: AgentEvent = { requestId: taskId, type: 'error', status: 'failed', message: 'Read 工具执行失败:missing file' } append('4', 0, error) expect(database.getConversation(conversationId).messages[1]?.tools?.map(tool => tool.state)).toEqual(['failed', 'failed']) expect(database.getTask(taskId).status).toBe('running') const recoveredRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, async *run(request: AgentExecutionRequest) { expect(request).toMatchObject({ requestId: taskId, remoteRecoveryOnly: true, remoteSemanticAfterSequence: '3', remoteHasResponseTextAfterToolFailure: true, remoteRecoveredTools: [ expect.objectContaining({ callId: 'A', state: 'failed', error: 'missing file' }), expect.objectContaining({ callId: 'B', state: 'running', input: 'preserve input' }) ] }) const remoteProvenance = { source: 'remote-semantic-transcript' as const, bindingId: 'partial-terminal', operationId: taskId, semanticSequence: '4', eventIndex: 0 } yield { requestId: taskId, type: 'done' as const, remoteProvenance } yield { requestId: taskId, type: 'remote-semantic-checkpoint' as const, remoteProvenance: { ...remoteProvenance, eventIndex: 1 } } } } const { harness } = createManagedSshHarness(recoveredRuntime) harness.assistantDatabase.getProject.mockImplementation(database.getProject.bind(database)) harness.assistantDatabase.getConversation.mockImplementation(database.getConversation.bind(database)) harness.assistantDatabase.listRecoverableRemoteTasks.mockImplementation(database.listRecoverableRemoteTasks.bind(database)) harness.assistantDatabase.getHighestCommittedRemoteTaskEventSequenceForTask.mockImplementation(database.getHighestCommittedRemoteTaskEventSequenceForTask.bind(database)) harness.assistantDatabase.hasRemoteResponseTextAfterToolFailure.mockImplementation(database.hasRemoteResponseTextAfterToolFailure.bind(database)) harness.assistantDatabase.getRemoteTaskActivityStates.mockImplementation(database.getRemoteTaskActivityStates.bind(database)) harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database)) try { harness.recoveryGetHandler?.(trustedEvent(harness.webContents)) await vi.waitFor(() => expect(database.getTask(taskId).status).toBe('failed')) expect(database.getHighestCommittedRemoteTaskEventSequenceForTask(taskId)).toBe('4') expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenNthCalledWith(1, expect.objectContaining({ semanticSequence: '4', eventIndex: 0, event: error })) expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mock.results.map(result => result.value)).toEqual([false, true]) expect(database.getRemoteTaskActivityStates(taskId).tools.get('B')).toBe('running') } finally { await harness.dispose() database.close() } }) it.each(['runtime', 'customization', 'capabilities'] as const)('restores concurrent live questions and their answer history across %s settings reload', async (settingsKind) => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-recovered-question-history-')) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize(directory) const project = database.createSshProject({ project: { name: 'Recovered questions', description: '', rootPath: '/srv/project', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } }, executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000850', remoteRootPath: '/srv/project' }, assertCurrent: () => {} }) const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` const tasks = [1, 2].map(n => ({ taskId: id(810 + n), conversationId: id(820 + n), projectId: project.id, currentUserMessageId: id(830 + n), currentAssistantMessageId: id(840 + n), instructions: 'Original question', workMode: 'execute' as const, status: 'running' as const })) for (const task of tasks) { database.saveLocalConversations([{ header: { id: task.conversationId, projectId: project.id, title: 'Recovered', updatedAt: Date.now(), runtimeSelection: { provider: 'opencode' } }, messages: [] }]) database.createTask({ id: task.taskId, conversationId: task.conversationId, projectId: project.id, title: 'Recovered question', instructions: task.instructions, workMode: task.workMode, remoteRecovery: { recoverable: true, currentUserMessageId: task.currentUserMessageId, currentAssistantMessageId: task.currentAssistantMessageId } }) } const ready = new Set() const signals: AbortSignal[] = [] const finish = new Map 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(resolve => { finish.set(request.requestId, resolve) }) ready.add(request.requestId) yield { requestId: request.requestId, type: 'question' as const, questionId: request.requestId, questions: [{ header: 'Input', question: 'Continue?', options: [], custom: true, multiple: false }] } await answered yield { requestId: request.requestId, type: 'done' as const, sessionId: request.requestId, remoteProvenance: { source: 'remote-semantic-transcript' as const, bindingId: request.requestId, operationId: request.requestId, semanticSequence: '1', eventIndex: 0 } } yield { requestId: request.requestId, type: 'remote-semantic-checkpoint' as const, remoteProvenance: { source: 'remote-semantic-transcript' as const, bindingId: request.requestId, operationId: request.requestId, semanticSequence: '1', eventIndex: 1 } } } } const { harness } = createManagedSshHarness(runtime) harness.assistantDatabase.getProject.mockReturnValue({ id: project.id, runtimeSelection: { provider: 'opencode' }, rootPath: '/srv/project', executionSpace: { kind: 'ssh', hostId: id(850), remoteRootPath: '/srv/project' } }) harness.assistantDatabase.getConversation.mockImplementation(database.getConversation.bind(database)) harness.assistantDatabase.listConversations.mockImplementation(database.listConversations.bind(database)) harness.assistantDatabase.listConversationSummaries.mockImplementation(database.listConversationSummaries.bind(database)) harness.assistantDatabase.listRecoverableRemoteTasks.mockImplementation(database.listRecoverableRemoteTasks.bind(database)) harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database)) harness.assistantDatabase.updateTaskStatus.mockImplementation(database.updateTaskStatus.bind(database)) harness.assistantDatabase.getTask.mockImplementation(database.getTask.bind(database)) harness.assistantDatabase.recordRemoteTaskQuestionArrival.mockImplementation(database.recordRemoteTaskQuestionArrival.bind(database)) harness.assistantDatabase.recordRemoteTaskQuestionAnswer.mockImplementation( (...args) => database.recordRemoteTaskQuestionAnswer(...args) ) const event = trustedEvent(harness.webContents) const list = () => electronMocks.handlers.get(ipcChannels.conversationsList)!(event) as ConversationSnapshot[] try { harness.recoveryGetHandler?.(event) await vi.waitFor(() => expect(ready.size).toBe(2)) await vi.waitFor(() => expect(list().every(c => c.activeRequest?.questions.length === 1)).toBe(true)) const summaries = electronMocks.handlers.get(ipcChannels.conversationsListSummaries)!(event, { detailIds: [] }) as ConversationSnapshot[] expect(summaries).toEqual(list()) for (const snapshot of summaries) { expect(electronMocks.handlers.get(ipcChannels.conversationsGet)!(event, snapshot.id)).toEqual(snapshot) } expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled() const update = settingsKind === 'runtime' ? [ipcChannels.runtimeSettingsUpdate, runtimeSettingsInputSchema.strip().parse({ ...defaultRuntimeSettings, workspacePath: directory, apiKey: { action: 'keep' } })] as const : settingsKind === 'customization' ? [ipcChannels.runtimeCustomizationUpdate, { opencode: { defaultAgent: 'build' }, continue: { presets: [] } }] as const : [ipcChannels.capabilitiesToggleWebSearch, false] as const await electronMocks.handlers.get(update[0])!(event, update[1]) expect(signals.every(signal => !signal.aborted)).toBe(true) expect(harness.approvalBroker.clear).not.toHaveBeenCalled() for (const [index, task] of [...tasks].reverse().entries()) { const snapshot = list().find(c => c.id === task.conversationId)! expect(snapshot.activeRequest).toMatchObject({ requestId: task.taskId, messageId: task.currentAssistantMessageId }) await electronMocks.handlers.get(ipcChannels.agentQuestionRespond)!(event, { questionId: task.taskId, answers: index === 0 ? [['Yes']] : [] }) } await vi.waitFor(() => expect(list().every(c => !c.activeRequest)).toBe(true)) expect(runtime.respondToQuestion.mock.calls.map(([questionId]) => questionId)) .toEqual([...tasks].reverse().map(task => task.taskId)) expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledTimes(4) database.close() const reopened = new AssistantDatabase(databasePath) reopened.initialize(directory) try { for (const [index, task] of tasks.entries()) { expect(reopened.getTask(task.taskId).status).toBe('completed') expect(reopened.getConversation(task.conversationId).messages[1]).toMatchObject({ id: task.currentAssistantMessageId, state: 'complete', blocks: [expect.objectContaining({ type: 'question', questionId: task.taskId })], answeredQuestions: [{ questionId: task.taskId, skipped: index === 0, questions: [{ header: 'Input', question: 'Continue?', options: [], multiple: false, custom: true, ...(index === 1 ? { answer: ['Yes'] } : {}) }] }] }) } } finally { reopened.close() } } finally { for (const resolve of finish.values()) resolve() await harness.dispose() database.close() await rm(directory, { recursive: true, force: true }) } }) it('aborts and drains a local run during Desktop shutdown', async () => { let localSignal: AbortSignal | undefined let finishLocalCleanup = (): void => undefined let markLocalCleanupStarted = (): void => undefined const localCleanupStarted = new Promise((resolve) => { markLocalCleanupStarted = resolve }) const localRuntime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: false, async *run( request: { requestId: string }, signal: AbortSignal ) { localSignal = signal await new Promise((resolve) => { signal.addEventListener('abort', () => resolve(), { once: true }) }) await new Promise((resolve) => { finishLocalCleanup = resolve markLocalCleanupStarted() }) signal.throwIfAborted() yield { requestId: request.requestId, type: 'done' } as const } } const harness = createHarness(localRuntime) const requestId = '00000000-0000-4000-8000-000000000754' await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'local-shutdown-conversation', prompt: 'local work', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(localSignal).toBeDefined()) const event = trustedEvent(harness.webContents) electronMocks.handlers.get( ipcChannels.appRendererPersistenceReady )?.(event) const disposal = harness.dispose() await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.appRendererPersistenceRequest, expect.any(String) ) ) expect(localSignal?.aborted).toBe(true) expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消') const persistenceRequestId = harness.webContents.send.mock.calls.find( ([channel]) => channel === ipcChannels.appRendererPersistenceRequest )?.[1] electronMocks.handlers.get( ipcChannels.appRendererPersistenceComplete )?.(event, persistenceRequestId) await localCleanupStarted finishLocalCleanup() await disposal expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消') }) it.each([false, true])('recovers user Stop only while its remote result is uncertain (confirmed=%s)', async (confirmed) => { let remoteSignal: AbortSignal | undefined let statusConsumed = false const remoteRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, async *run( request: { requestId: string }, signal: AbortSignal ) { remoteSignal = signal yield { requestId: request.requestId, type: 'status', message: '远端 ACP Runtime 正在处理请求' } as const statusConsumed = true await new Promise((resolve) => { signal.addEventListener('abort', () => resolve(), { once: true }) }) if (confirmed) { throw new RemotePromptCancelledError('请求已取消') } signal.throwIfAborted() yield { requestId: request.requestId, type: 'done' } as const } } const selectedRuntimes = { getRuntime: vi.fn(async () => remoteRuntime) } const harness = createHarness( remoteRuntime, undefined, 'always', undefined, false, selectedRuntimes ) const projectId = '00000000-0000-4000-8000-000000000755' harness.assistantDatabase.getProject.mockReturnValue({ id: projectId, rootPath: '/srv/project', executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000756', remoteRootPath: '/srv/project' } }) const requestId = '00000000-0000-4000-8000-000000000757' await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'managed-ssh-stop-conversation', projectId, runtimeSelection: { provider: 'opencode' }, prompt: 'stop this', workMode: 'execute', knowledgeLibraryIds: [], currentUserMessageId: '00000000-0000-4000-8000-000000000758', currentAssistantMessageId: '00000000-0000-4000-8000-000000000759' }) await vi.waitFor(() => expect(statusConsumed).toBe(true)) harness.cancelHandler?.( trustedEvent(harness.webContents), requestId ) expect(remoteSignal?.aborted).toBe(true) if (confirmed) { await vi.waitFor(() => expect( harness.assistantDatabase.endRecoverableRemoteTask ).toHaveBeenCalledWith(requestId, '请求已取消', 'cancelled')) expect(harness.assistantDatabase.appendTaskEvent).not.toHaveBeenCalledWith( requestId, 'error', expect.anything() ) expect(harness.webContents.send).not.toHaveBeenCalledWith( ipcChannels.remoteProjectRecoveryProgress, expect.anything() ) } else { await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'interrupted', '请求已取消')) } await harness.dispose() }) it('keeps a managed SSH start ambiguity recoverable before its first event', async () => { const remoteRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, async *run(): AsyncGenerator { await Promise.reject( new Error('prompt start response was lost') ) yield undefined as never } } const selectedRuntimes = { getRuntime: vi.fn(async () => remoteRuntime) } const harness = createHarness( remoteRuntime, undefined, 'always', undefined, false, selectedRuntimes ) const projectId = '00000000-0000-4000-8000-000000000760' harness.assistantDatabase.getProject.mockReturnValue({ id: projectId, rootPath: '/srv/project', executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000761', remoteRootPath: '/srv/project' } }) const requestId = '00000000-0000-4000-8000-000000000762' await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'managed-ssh-start-ambiguity', projectId, runtimeSelection: { provider: 'opencode' }, prompt: 'start once', workMode: 'execute', knowledgeLibraryIds: [], currentUserMessageId: '00000000-0000-4000-8000-000000000763', currentAssistantMessageId: '00000000-0000-4000-8000-000000000764' }) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith( requestId, 'interrupted', 'prompt start response was lost' ) ) await harness.dispose() }) it('reserves a Conversation while its Runtime is resolving', async () => { const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: false, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const } } let resolveRuntime: | ((value: typeof runtime) => void) | undefined const runtimeResolution = new Promise((resolve) => { resolveRuntime = resolve }) const selectedRuntimes = { getRuntime: vi.fn(() => runtimeResolution) } const harness = createHarness( runtime, undefined, 'always', undefined, false, selectedRuntimes ) const conversationId = '00000000-0000-4000-8000-000000000724' const firstRun = harness.handler?.( trustedEvent(harness.webContents), { requestId: '00000000-0000-4000-8000-000000000728', conversationId, runtimeSelection: { provider: 'model' }, prompt: '等待 Runtime', workMode: 'ask', knowledgeLibraryIds: [] } ) await vi.waitFor(() => expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce() ) await expect( harness.handler?.(trustedEvent(harness.webContents), { requestId: '00000000-0000-4000-8000-000000000729', conversationId, runtimeSelection: { provider: 'model' }, prompt: '不能并发', workMode: 'ask', knowledgeLibraryIds: [] }) ).rejects.toThrow('当前对话已有执行中的请求') expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce() expect(() => harness.branchHandler?.(trustedEvent(harness.webContents), { sourceConversationId: conversationId, title: '等待 Runtime · 分支' }) ).toThrow('当前会话仍有正在执行的请求') expect( harness.assistantDatabase.branchLocalConversation ).not.toHaveBeenCalled() resolveRuntime?.(runtime) await firstRun await harness.dispose() }) it('interrupts the active response and promotes the selected queue item', async () => { const runtimeStarted = vi.fn() const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: false, async *run( request: { requestId: string }, signal: AbortSignal ) { runtimeStarted(request.requestId) await new Promise((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 }, workMode: 'ask' as const, includeMemoryContext: true, prompt: item.label, attachments: [], knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto' as const } harness.assistantDatabase.getConversationQueueItem.mockReturnValue( item ) harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({ source: 'user', item, payloadJson: JSON.stringify(input) }) electronMocks.handlers.get( ipcChannels.conversationQueueReady )?.(trustedEvent(harness.webContents), conversationId) await harness.handler?.(trustedEvent(harness.webContents), { requestId: '00000000-0000-4000-8000-000000000727', conversationId, prompt: '当前回复', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled()) electronMocks.handlers.get( ipcChannels.conversationQueueInterruptAndRun )?.(trustedEvent(harness.webContents), item.id) await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.conversationQueueDispatch, { item, input } ) ) expect( harness.assistantDatabase.claimConversationQueueItem ).toHaveBeenCalledWith(conversationId, item.id) await harness.dispose() }) it('rejects unconfirmed image capability before accepting a queued input', async () => { const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const } }) harness.contextManager.hasImageInputs.mockReturnValue(true) try { await expect(electronMocks.handlers.get(ipcChannels.conversationQueueEnqueueUser)?.(trustedEvent(harness.webContents), { conversationId: '00000000-0000-4000-8000-000000000731', workMode: 'ask', prompt: 'Read the picture', attachments: [{ id: '00000000-0000-4000-8000-000000000734', name: 'image.png', size: 10, preview: '', kind: 'image' }], knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto' })).rejects.toThrow('图片输入') expect(harness.assistantDatabase.enqueueConversationUserInput).not.toHaveBeenCalled() expect(harness.contextManager.remove).not.toHaveBeenCalled() } finally { await harness.dispose() } }) it('claims an idle user message before notifying the renderer and accepts its dispatch', async () => { const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: false, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const } } const harness = createHarness(runtime) const conversationId = '00000000-0000-4000-8000-000000000731' const itemId = '00000000-0000-4000-8000-000000000732' const attachmentId = '00000000-0000-4000-8000-000000000734' const input = { conversationId, runtimeSelection: { provider: 'model' as const }, workMode: 'ask' as const, includeMemoryContext: true, prompt: '排队发送', attachments: [ { id: attachmentId, name: 'queued.txt', size: 6, preview: 'queued', kind: 'text' as const } ], knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto' as const } const item = { id: itemId, conversationId, source: 'user' as const, label: input.prompt, createdAt: '2026-08-19T00:01:00.000Z' } harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue( item ) harness.assistantDatabase.listConversationQueueItems .mockReturnValueOnce([item]) .mockReturnValue([]) harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({ source: 'user', item, payloadJson: JSON.stringify({ input, serializedContexts: '[]' }) }) harness.assistantDatabase.getConversationQueueItem.mockReturnValue( item ) const enqueueHandler = electronMocks.handlers.get( ipcChannels.conversationQueueEnqueueUser ) harness.webContents.send.mockClear() expect( await enqueueHandler?.(trustedEvent(harness.webContents), input) ).toEqual(item) expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.conversationQueueDispatch, { item, input } ) const queueChangeIndex = harness.webContents.send.mock.calls.findIndex( ([channel]) => channel === ipcChannels.conversationQueueChanged ) expect(queueChangeIndex).toBeGreaterThanOrEqual(0) expect( harness.assistantDatabase.claimConversationQueueItem.mock.invocationCallOrder[0] ).toBeLessThan( harness.webContents.send.mock.invocationCallOrder[queueChangeIndex] ?? 0 ) await harness.handler?.(trustedEvent(harness.webContents), { requestId: '00000000-0000-4000-8000-000000000733', conversationId, queueItemId: itemId, runtimeSelection: input.runtimeSelection, prompt: input.prompt, workMode: input.workMode, knowledgeLibraryIds: [] }) expect( harness.assistantDatabase.completeConversationUserQueueItem ).toHaveBeenCalledWith(itemId) expect(harness.contextManager.serializeForQueue).toHaveBeenCalledWith([ attachmentId ]) expect(harness.contextManager.remove).toHaveBeenCalledWith( attachmentId ) expect(harness.contextManager.restoreFromQueue).toHaveBeenCalledWith( '[]' ) await harness.dispose() }) it('releases the queue reservation when a dispatched run cannot start', async () => { const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: false, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const } } const harness = createHarness(runtime) const conversationId = '00000000-0000-4000-8000-000000000745' const itemId = '00000000-0000-4000-8000-000000000746' const input = { conversationId, runtimeSelection: { provider: 'model' as const }, workMode: 'ask' as const, includeMemoryContext: true, prompt: '需要重新排队', attachments: [], knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto' as const } const item = { id: itemId, conversationId, source: 'user' as const, label: input.prompt, createdAt: '2026-08-21T00:02:00.000Z' } harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue( item ) harness.assistantDatabase.listConversationQueueItems .mockReturnValueOnce([item]) .mockReturnValue([]) harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce( { source: 'user', item, payloadJson: JSON.stringify({ input, serializedContexts: '[]' }) } ) harness.assistantDatabase.getConversationQueueItem.mockReturnValue( item ) harness.assistantDatabase.createTask.mockImplementationOnce(() => { throw new Error('会话记录不可用') }) electronMocks.handlers.get( ipcChannels.conversationQueueEnqueueUser )?.(trustedEvent(harness.webContents), input) await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.conversationQueueDispatch, { item, input } ) ) await expect( harness.handler?.(trustedEvent(harness.webContents), { requestId: '00000000-0000-4000-8000-000000000747', conversationId, queueItemId: itemId, runtimeSelection: input.runtimeSelection, prompt: input.prompt, workMode: input.workMode, knowledgeLibraryIds: [] }) ).rejects.toThrow('会话记录不可用') expect( harness.assistantDatabase.releaseConversationUserQueueItem ).toHaveBeenCalledWith(itemId) await harness.handler?.(trustedEvent(harness.webContents), { requestId: '00000000-0000-4000-8000-000000000748', conversationId, runtimeSelection: { provider: 'model' }, prompt: '失败后的新消息', workMode: 'ask', knowledgeLibraryIds: [] }) await harness.dispose() }) it('dispatches a message queued during a run that later fails', async () => { let failRun: ((error: Error) => void) | undefined const runtimeStarted = vi.fn() const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: false, // eslint-disable-next-line require-yield async *run(request: { requestId: string }) { runtimeStarted(request.requestId) throw await new Promise((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 }, workMode: 'ask' as const, includeMemoryContext: true, prompt, attachments: [], knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto' as const } return { input, item: { id, conversationId, source: 'user' as const, label: prompt, createdAt: '2026-08-21T00:03:00.000Z' } } } const first = createItem( '00000000-0000-4000-8000-000000000752', '会失败的消息' ) const second = createItem( '00000000-0000-4000-8000-000000000753', '失败之后的新消息' ) harness.assistantDatabase.enqueueConversationUserInput .mockReturnValueOnce(first.item) .mockReturnValueOnce(second.item) harness.assistantDatabase.listConversationQueueItems .mockReturnValueOnce([first.item]) .mockReturnValueOnce([second.item]) .mockReturnValue([]) harness.assistantDatabase.claimConversationQueueItem .mockReturnValueOnce({ source: 'user', item: first.item, payloadJson: JSON.stringify({ input: first.input, serializedContexts: '[]' }) }) .mockReturnValueOnce({ source: 'user', item: second.item, payloadJson: JSON.stringify({ input: second.input, serializedContexts: '[]' }) }) harness.assistantDatabase.getConversationQueueItem.mockImplementation( (id: string) => id === first.item.id ? first.item : second.item ) electronMocks.handlers.get( ipcChannels.conversationQueueEnqueueUser )?.(trustedEvent(harness.webContents), first.input) await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.conversationQueueDispatch, { item: first.item, input: first.input } ) ) await harness.handler?.(trustedEvent(harness.webContents), { requestId: '00000000-0000-4000-8000-000000000754', conversationId, queueItemId: first.item.id, runtimeSelection: first.input.runtimeSelection, prompt: first.input.prompt, workMode: first.input.workMode, knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled()) // The next message arrives while the run is still in flight, so the // renderer never reports readiness for it afterwards. electronMocks.handlers.get( ipcChannels.conversationQueueEnqueueUser )?.(trustedEvent(harness.webContents), second.input) failRun?.(new Error('模型接口请求失败')) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith( '00000000-0000-4000-8000-000000000754', 'failed', expect.any(String) ) ) await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.conversationQueueDispatch, { item: second.item, input: second.input } ) ) await harness.dispose() }) it('publishes Runtime usage as context metrics with one settings read', async () => { const runtime = { runtimeId: 'continue', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { for (const [index, inputTokens] of [100, 120].entries()) { yield { requestId: request.requestId, type: 'model-usage', callId: `continue-call-${index}`, runtime: 'continue', provider: 'anthropic', model: 'summary-model', inputTokens, outputTokens: 20, cacheReadTokens: 10, cacheWriteTokens: 5 } as const } yield { requestId: request.requestId, type: 'done' } as const } } const harness = createHarness(runtime) harness.getResolvedSettings.mockResolvedValue({ toolApproval: 'always', subagentSmartRoutingEnabled: false, continueModelProfile: { contextWindowTokens: 32_000 }, contextCompression: { triggerTokens: 20_000 } }) const requestId = '00000000-0000-4000-8000-000000000020' await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'continue-context', prompt: 'report context usage', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) const metrics = harness.webContents.send.mock.calls .filter(([channel]) => channel === ipcChannels.agentEvent) .map(([, event]) => event) .filter( (event): event is AgentEvent => (event as AgentEvent).type === 'context-metrics' ) expect(metrics).toEqual([ { requestId, type: 'context-metrics', contextTokens: 115, effectiveTriggerTokens: 32_000, contextWindowTokens: 32_000, compressionEnabled: false, source: 'provider', basis: 'model-call' }, { requestId, type: 'context-metrics', contextTokens: 135, effectiveTriggerTokens: 32_000, contextWindowTokens: 32_000, compressionEnabled: false, source: 'provider', basis: 'model-call' } ]) expect(harness.getResolvedSettings).toHaveBeenCalledOnce() await harness.dispose() }) it('rejects unknown knowledge scope and creates no capability for empty scope', async () => { const libraryId = '11111111-1111-4111-8111-111111111111' const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } } } const knowledgeGateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => ['knowledge_search']), drainReferences: vi.fn(() => []), revoke: vi.fn() } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, { database: { listKnowledgeBases: vi.fn(() => [ { id: libraryId, name: 'Known' } ]) } }, knowledgeGateway ) const event = trustedEvent(harness.webContents) await expect( harness.handler?.(event, { requestId: '00000000-0000-4000-8000-000000000021', conversationId: 'unknown-scope', prompt: 'test', workMode: 'ask', knowledgeLibraryIds: [ '22222222-2222-4222-8222-222222222222' ] }) ).rejects.toThrow('不存在的知识库') expect(knowledgeGateway.grant).not.toHaveBeenCalled() await harness.handler?.(event, { requestId: '00000000-0000-4000-8000-000000000022', conversationId: 'empty-scope', prompt: 'test', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( '00000000-0000-4000-8000-000000000022', 'completed' ) ) expect(knowledgeGateway.grant).not.toHaveBeenCalled() await harness.dispose() }) it('retains user-enabled knowledge scopes when other applications are disabled', async () => { const libraryId = '11111111-1111-4111-8111-111111111111' let finish: (() => void) | undefined const held = new Promise(resolve => { finish = resolve }) const run = vi.fn(async function* (request: { requestId: string; knowledgeLibraryIds: string[] }) { if (request.requestId.endsWith('21')) await held yield { requestId: request.requestId, type: 'done' } }) const retrieveMany = vi.fn(async () => []) const gateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => ['knowledge_search']), drainReferences: vi.fn(() => []), revoke: vi.fn() } const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, supportsScopedDataTools: true, run }, undefined, 'always', undefined, false, undefined, { database: { listKnowledgeBases: vi.fn(() => [{ id: libraryId, name: 'Knowledge' }]) }, retrieveMany }, gateway) const request = { prompt: 'use saved knowledge', workMode: 'ask', knowledgeLibraryIds: [libraryId], knowledgeRetrievalMode: 'always' } try { await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000021', conversationId: 'in-flight' }) await vi.waitFor(() => expect(run).toHaveBeenCalledOnce()) expect(retrieveMany).toHaveBeenCalledOnce() harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: false, magicNotesEnabled: false }) await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000022', conversationId: 'disabled' }) await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2)) expect(run.mock.calls[0]![0].knowledgeLibraryIds).toEqual([libraryId]) expect(run.mock.calls[1]![0].knowledgeLibraryIds).toEqual([libraryId]) expect(retrieveMany).toHaveBeenCalledTimes(2) expect(gateway.grant).toHaveBeenCalledTimes(2) expect(harness.assistantDatabase.updateTaskStatus).not.toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'cancelled') finish!() await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'completed')) harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: true, magicNotesEnabled: false }) await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000023', conversationId: 'restored' }) await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(3)) expect(run.mock.calls[2]![0].knowledgeLibraryIds).toEqual([libraryId]) expect(retrieveMany).toHaveBeenCalledTimes(3) expect(request.knowledgeLibraryIds).toEqual([libraryId]) } finally { finish!() await harness.dispose() } }) it.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 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 terminal: (runtime: string, write: (data: string) => Promise, output: () => string) => Promise finish: () => Promise } : undefined if (live) vi.stubGlobal('fetch', live.begin(fetch)) let coordinator: InstanceType | undefined let harness: ReturnType | undefined let terminalManager: InstanceType | undefined try { database.initialize(root) const secrets = new Map() 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 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, defaultWorkMode: 'ask', runtimeSelection: { provider: 'deepseek-harness', model: { kind: 'profile', profileId } } }) const conversationId = '00000000-0000-4000-8000-000000000222' const now = Date.now() const save = async (): Promise => { 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, applicationSettingsStore, capabilities, executionSpaceResolver: new ExecutionSpaceResolver(), terminalManager, localEnvironment: { launchEnvironmentProvider: () => process.env } 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, 'always', 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, workMode: 'execute' }, messages: [] }]) expect(await coordinator.get(9, conversationId)).toBeNull() database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Native UI', updatedAt: now + 2, workMode: 'ask' }, 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(' 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 | 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, 'always', 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() expect(() => save(event, { vaultPath: 42 })).toThrow() await expect(test(event, { vaultPath: 42 })).rejects.toThrow() const untrustedEvent = { ...event, sender: { ...harness.webContents, id: 99 } } expect(() => save(untrustedEvent, { vaultPath })).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, workMode: 'ask' as const, access: undefined }, { enabled: true, assigned: false, workMode: 'execute' as const, access: undefined }, { enabled: true, assigned: true, workMode: 'ask' as const, access: 'read' }, { enabled: true, assigned: true, workMode: 'execute' as const, access: 'write' } ])('routes Obsidian request grants with saved settings: %j', async ({ enabled, assigned, workMode, access }) => { const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-obsidian-grant-')) let harness: ReturnType | 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, 'always', 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', workMode, 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, 'always', undefined, false, undefined, undefined, gateway, false, undefined, { getEnabledBuiltinMcpServerIds: vi.fn(async () => ['story-graph']) }) try { const projectId = '00000000-0000-4000-8000-000000000088' for (const enabled of [true, false]) { harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: enabled }) const requestId = crypto.randomUUID() await harness.handler!(trustedEvent(harness.webContents), { requestId, projectId, conversationId: 'graph-grant', prompt: 'Continue work', workMode: 'ask', 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, runtimeTarget: runtimeId }) expect(received[0]).toMatchObject({ knowledgeCapabilityToken: 'graph-capability', storyGraphBinding: binding }) expect(received[1]).not.toHaveProperty('storyGraphBinding') expect(gateway.bindRemoteStoryGraph).toHaveBeenCalledOnce() } finally { await harness.dispose() } }) it('grants read-only Magic Notes tools in Ask and write tools in Execute', async () => { const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } } } const knowledgeGateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => { const grantCallCount = knowledgeGateway.grant.mock.calls.length return grantCallCount === 1 ? ['note_list', 'note_get', 'note_search'] : [ 'note_list', 'note_get', 'note_search', 'note_create', 'note_update', 'note_entry_create', 'note_entry_update', 'note_entry_delete', 'note_delete' ] }), drainReferences: vi.fn(() => []), revoke: vi.fn() } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, true ) const event = trustedEvent(harness.webContents) const askRequestId = '00000000-0000-4000-8000-000000000023' const executeRequestId = '00000000-0000-4000-8000-000000000024' await harness.handler?.(event, { requestId: askRequestId, conversationId: 'notes-read', prompt: '读取笔记', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( askRequestId, 'completed' ) ) await harness.handler?.(event, { requestId: executeRequestId, conversationId: 'notes-write', prompt: '创建笔记', workMode: 'execute', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( executeRequestId, 'completed' ) ) expect(knowledgeGateway.grant).toHaveBeenNthCalledWith( 1, askRequestId, [], expect.any(AbortSignal), 'read' ) expect(knowledgeGateway.grant).toHaveBeenNthCalledWith( 2, executeRequestId, [], expect.any(AbortSignal), 'write' ) await harness.dispose() }) it('does not grant a built-in MCP that is disabled or unassigned for the runtime', async () => { const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } } } const knowledgeGateway = { grant: vi.fn(() => 'must-not-be-granted'), getAvailableToolNames: vi.fn(() => ['note_list']), drainReferences: vi.fn(() => []), revoke: vi.fn() } const getEnabledBuiltinMcpServerIds = vi.fn(async () => [ 'knowledge-base' ]) const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, true, undefined, { getEnabledBuiltinMcpServerIds } ) const requestId = '00000000-0000-4000-8000-000000000025' await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'disabled-notes', prompt: '读取笔记', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledWith('model') expect(knowledgeGateway.grant).not.toHaveBeenCalled() await harness.dispose() }) it('grants the assigned built-in browser only to managed Runtime Execute', async () => { const receivedRequests: Array<{ requestId: string knowledgeCapabilityToken?: string trustedInstructions?: string browserTabId?: BrowserTabId }> = [] const runtime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, supportsScopedDataTools: true, async *run(request: { requestId: string knowledgeCapabilityToken?: string trustedInstructions?: string browserTabId?: BrowserTabId }) { receivedRequests.push(request) yield { requestId: request.requestId, type: 'done' } } } const knowledgeGateway = { grant: vi.fn(() => 'browser-capability'), getAvailableToolNames: vi.fn(() => [ 'browser_navigate', 'browser_snapshot' ]), drainReferences: vi.fn(() => []), revoke: vi.fn() } const getEnabledBuiltinMcpServerIds = vi.fn(async () => [ 'builtin-browser' ]) const primaryTabId = browserTabIdSchema.parse( '00000000-0000-4000-8000-000000000401' ) const siblingTabId = browserTabIdSchema.parse( '00000000-0000-4000-8000-000000000402' ) const browserControl = { listTabs: vi.fn(() => [ { tabId: siblingTabId, primary: false }, { tabId: primaryTabId, primary: true } ]), getVisibleTabId: vi.fn(() => undefined), acquireTabUsage: vi.fn( (conversationId: string, tabId: BrowserTabId, owner: string) => ({ conversationId, tabId, owner, signal: new AbortController().signal, release: vi.fn() }) ), createTab: vi.fn(), onState: vi.fn(() => () => undefined) } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, false, undefined, { getEnabledBuiltinMcpServerIds }, browserControl ) const event = trustedEvent(harness.webContents) const askRequestId = '00000000-0000-4000-8000-000000000026' const executeRequestId = '00000000-0000-4000-8000-000000000027' await harness.handler?.(event, { requestId: askRequestId, conversationId: 'browser-ask', prompt: '只回答', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( askRequestId, 'completed' ) ) await harness.handler?.(event, { requestId: executeRequestId, conversationId: 'browser-execute', prompt: '打开网页', workMode: 'execute', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( executeRequestId, 'completed' ) ) expect(knowledgeGateway.grant).toHaveBeenCalledOnce() expect(knowledgeGateway.grant).toHaveBeenCalledWith( executeRequestId, [], expect.any(AbortSignal), 'none', undefined, 'browser-execute', primaryTabId, expect.objectContaining({ conversationId: 'browser-execute', tabId: primaryTabId, owner: executeRequestId }) ) expect(receivedRequests[0]?.knowledgeCapabilityToken).toBeUndefined() expect(receivedRequests[1]).toMatchObject({ knowledgeCapabilityToken: 'browser-capability', browserTabId: primaryTabId, trustedInstructions: expect.stringContaining('browser_navigate') }) expect(browserControl.listTabs).toHaveBeenCalledWith( 'browser-execute', harness.webContents.id ) expect(browserControl.createTab).not.toHaveBeenCalled() expect(browserControl.acquireTabUsage).toHaveBeenCalledWith( 'browser-execute', primaryTabId, executeRequestId, harness.webContents.id ) await harness.dispose() }) it('prefers the owner window visible browser tab over the primary tab', async () => { const requestId = '00000000-0000-4000-8000-000000000028' const primaryTabId = browserTabIdSchema.parse( '00000000-0000-4000-8000-000000000411' ) const visibleTabId = browserTabIdSchema.parse( '00000000-0000-4000-8000-000000000412' ) const received: Array<{ browserTabId?: BrowserTabId }> = [] const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string; browserTabId?: BrowserTabId }) { received.push(request) yield { requestId: request.requestId, type: 'done' } } } const release = vi.fn() const knowledgeGateway = { grant: vi.fn(() => 'browser-capability'), getAvailableToolNames: vi.fn(() => ['browser_navigate']), drainReferences: vi.fn(() => []), revoke: vi.fn() } const browserControl = { listTabs: vi.fn(() => [ { tabId: primaryTabId, primary: true }, { tabId: visibleTabId, primary: false } ]), getVisibleTabId: vi.fn(() => visibleTabId), acquireTabUsage: vi.fn( (conversationId: string, tabId: BrowserTabId, owner: string) => ({ conversationId, tabId, owner, signal: new AbortController().signal, release }) ), createTab: vi.fn(), onState: vi.fn(() => () => undefined) } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, false, undefined, { getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) }, browserControl ) await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'visible-browser', prompt: '使用当前网页', workMode: 'execute', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect(received[0]?.browserTabId).toBe(visibleTabId) expect(browserControl.acquireTabUsage).toHaveBeenCalledWith( 'visible-browser', visibleTabId, requestId, harness.webContents.id ) expect(browserControl.createTab).not.toHaveBeenCalled() await harness.dispose() }) it('completes the runtime request and releases its lease after browser lease abort', async () => { const requestId = '00000000-0000-4000-8000-000000000031' const tabId = browserTabIdSchema.parse( '00000000-0000-4000-8000-000000000423' ) const leaseController = new AbortController() const release = vi.fn() const lease = { conversationId: 'browser-lease-abort', tabId, owner: requestId, signal: leaseController.signal, release } let runtimeSignal: AbortSignal | undefined const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }, signal: AbortSignal) { runtimeSignal = signal leaseController.abort(new Error('browser tab closed')) signal.throwIfAborted() yield { requestId: request.requestId, type: 'done' } } } let grantedLease: { release(): void } | undefined const knowledgeGateway = { grant: vi.fn((...args: unknown[]) => { grantedLease = args[7] as { release(): void } return 'browser-capability' }), getAvailableToolNames: vi.fn(() => ['browser_navigate']), drainReferences: vi.fn(() => []), revoke: vi.fn(() => grantedLease?.release()) } const browserControl = { listTabs: vi.fn(() => [{ tabId, primary: true }]), getVisibleTabId: vi.fn(() => tabId), acquireTabUsage: vi.fn(() => lease), createTab: vi.fn(), onState: vi.fn(() => () => undefined) } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, false, undefined, { getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) }, browserControl ) try { await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: lease.conversationId, prompt: 'Continue after the browser tab closes', workMode: 'execute', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(release).toHaveBeenCalledOnce()) expect(grantedLease).toBe(lease) expect(lease.signal.aborted).toBe(true) expect(runtimeSignal?.aborted).toBe(false) expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) const publicEvents = harness.webContents.send.mock.calls .filter(([channel]) => channel === ipcChannels.agentEvent) .map(([, payload]) => payload) expect(publicEvents).toContainEqual({ requestId, type: 'done' }) expect(publicEvents).not.toEqual(expect.arrayContaining([ expect.objectContaining({ type: 'error' }) ])) expect(knowledgeGateway.revoke).toHaveBeenCalledWith('browser-capability') } finally { await harness.dispose() } }) it.each(['error', 'cancel'] as const)( 'releases a request-created browser tab lease on runtime %s', async (outcome) => { const requestId = outcome === 'error' ? '00000000-0000-4000-8000-000000000029' : '00000000-0000-4000-8000-000000000030' const tabId = browserTabIdSchema.parse( outcome === 'error' ? '00000000-0000-4000-8000-000000000421' : '00000000-0000-4000-8000-000000000422' ) let markStarted!: () => void const started = new Promise((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((_resolve, reject) => { signal.addEventListener('abort', () => reject(signal.reason), { once: true }) }) yield { requestId: request.requestId, type: 'done' } } } const release = vi.fn() let grantedLease: | { release(): void } | undefined const knowledgeGateway = { grant: vi.fn((...args: unknown[]) => { grantedLease = args[7] as { release(): void } return 'browser-capability' }), getAvailableToolNames: vi.fn(() => ['browser_navigate']), drainReferences: vi.fn(() => []), revoke: vi.fn(() => grantedLease?.release()) } const createTab = vi.fn() const browserControl = { listTabs: vi.fn(() => []), getVisibleTabId: vi.fn(() => undefined), createTab, reserveRequestTab: vi.fn((conversationId: string, owner: string) => ({ conversationId, tabId, owner, signal: new AbortController().signal, release })), acquireTabUsage: vi.fn( (conversationId: string, acquiredTabId: BrowserTabId, owner: string) => ({ conversationId, tabId: acquiredTabId, owner, signal: new AbortController().signal, release }) ), onState: vi.fn(() => () => undefined) } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, false, undefined, { getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) }, browserControl ) const event = trustedEvent(harness.webContents) await harness.handler?.(event, { requestId, conversationId: `browser-${outcome}`, prompt: '使用浏览器', workMode: 'execute', knowledgeLibraryIds: [] }) await started if (outcome === 'cancel') { harness.cancelHandler?.(event, requestId) } await vi.waitFor(() => expect(release).toHaveBeenCalledOnce()) expect(createTab).not.toHaveBeenCalled() expect(browserControl.acquireTabUsage).not.toHaveBeenCalled() expect(browserControl.reserveRequestTab).toHaveBeenCalledWith( `browser-${outcome}`, requestId, harness.webContents.id ) expect(knowledgeGateway.revoke).toHaveBeenCalledWith( 'browser-capability' ) await harness.dispose() } ) it.each(['ask', 'execute'] as const)( 'does not grant or advertise scoped data tools to external OpenCode in %s mode', async (workMode) => { let receivedRequest: | { knowledgeCapabilityToken?: string trustedInstructions?: string } | undefined const externalOpenCode = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, supportsScopedDataTools: false, async *run(request: { requestId: string knowledgeCapabilityToken?: string trustedInstructions?: string }) { receivedRequest = request yield { requestId: request.requestId, type: 'done' } } } const knowledgeGateway = { grant: vi.fn(() => 'must-not-be-granted'), getAvailableToolNames: vi.fn(() => ['note_list']), drainReferences: vi.fn(() => []), revoke: vi.fn() } const harness = createHarness( externalOpenCode, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, true ) const requestId = '00000000-0000-4000-8000-000000000025' await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'external-opencode', prompt: '读取笔记', workMode, knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect(knowledgeGateway.grant).not.toHaveBeenCalled() expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined() expect(receivedRequest?.trustedInstructions).not.toContain( 'note_list' ) expect(receivedRequest?.trustedInstructions).not.toContain( 'Available GoodBuddy data tools:' ) await harness.dispose() } ) it('accepts an authorized knowledge library after the first 100 entries', async () => { const libraries = Array.from({ length: 101 }, (_, index) => ({ id: `00000000-0000-4000-8000-${index .toString(16) .padStart(12, '0')}`, name: `Library ${index}` })) const listKnowledgeBases = vi.fn(() => libraries) const knowledgeGateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => ['knowledge_search']), drainReferences: vi.fn(() => []), revoke: vi.fn() } const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, { database: { listKnowledgeBases } }, knowledgeGateway ) const requestId = '00000000-0000-4000-8000-000000000024' await expect( harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'later-library', prompt: 'search', workMode: 'ask', knowledgeLibraryIds: [libraries[100]!.id] }) ).resolves.toBeUndefined() await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect(listKnowledgeBases).toHaveBeenCalledWith(500) expect(knowledgeGateway.grant).toHaveBeenCalledWith( requestId, [libraries[100]!.id], expect.any(AbortSignal), 'none' ) await harness.dispose() }) it('emits drained knowledge references immediately before done', async () => { const libraryId = '11111111-1111-4111-8111-111111111111' const reference = { libraryId, libraryName: 'Known', documentId: '33333333-3333-4333-8333-333333333333', documentName: 'Doc', sourceName: 'Source', snippet: 'Evidence', rank: 1 } const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string knowledgeCapabilityToken?: string }) { expect(request.knowledgeCapabilityToken).toBe('capability') yield { requestId: request.requestId, type: 'done' } } } const knowledgeGateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => ['knowledge_search']), drainReferences: vi.fn(() => [reference]), revoke: vi.fn() } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, { database: { listKnowledgeBases: vi.fn(() => [ { id: libraryId, name: 'Known' } ]) } }, knowledgeGateway ) const requestId = '00000000-0000-4000-8000-000000000023' await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'scoped', prompt: 'search', workMode: 'ask', knowledgeLibraryIds: [libraryId, libraryId] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect(knowledgeGateway.grant).toHaveBeenCalledWith( requestId, [libraryId], expect.any(AbortSignal), 'none' ) const publicEvents = harness.webContents.send.mock.calls .filter(([channel]) => channel === ipcChannels.agentEvent) .map(([, payload]) => payload) expect(publicEvents.slice(-2)).toEqual([ { requestId, type: 'source-references', references: [reference] }, { requestId, type: 'done' } ]) expect(knowledgeGateway.revoke).toHaveBeenCalledWith('capability') await harness.dispose() }) it('coalesces burst deltas while preserving output and terminal order', async () => { const deltas = Array.from( { length: 100 }, (_, index) => `chunk-${index};` ) const expectedOutput = deltas.join('') const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { for (const delta of deltas) { yield { requestId: request.requestId, type: 'text', delta } } yield { requestId: request.requestId, type: 'tool', callId: 'call-burst', name: 'read', state: 'completed', summary: 'read completed' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const requestId = '00000000-0000-4000-8000-000000000025' await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'burst-stream', prompt: 'stream', workMode: 'ask', knowledgeLibraryIds: [] }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) const persistedEvents = harness.assistantDatabase.appendTaskEvent.mock.calls .filter(([taskId]) => taskId === requestId) .map(([, , payload]) => payload) const publicEvents = harness.webContents.send.mock.calls .filter(([channel]) => channel === ipcChannels.agentEvent) .map(([, payload]) => payload) .filter((payload) => payload.requestId === requestId) expect(persistedEvents).toHaveLength(3) expect(publicEvents).toHaveLength(4) expect(persistedEvents.map((event) => event.type)).toEqual([ 'text', 'tool', 'done' ]) expect(publicEvents.map((event) => event.type)).toEqual([ 'text', 'text', 'tool', 'done' ]) expect(publicEvents[0]).toEqual({ requestId, type: 'text', delta: deltas[0] }) expect(persistedEvents[0]).toEqual({ requestId, type: 'text', delta: expectedOutput }) expect( publicEvents .filter( ( event ): event is Extract => event.type === 'text' ) .map((event) => event.delta) .join('') ).toBe(expectedOutput) expect( harness.assistantDatabase.createTextArtifact ).not.toHaveBeenCalled() await harness.dispose() }) it.each([false, true])('preflights always-retrieve mode with external=%s and injects bounded untrusted evidence', async (external) => { const libraryId = '11111111-1111-4111-8111-111111111111' const documentId = '33333333-3333-4333-8333-333333333333' const chunkId = '44444444-4444-4444-8444-444444444444' const run = vi.fn(async function* (request: { requestId: string prompt: string trustedInstructions?: string }) { expect(request.prompt).toContain( 'BEGIN_UNTRUSTED_KNOWLEDGE_EVIDENCE' ) expect(request.prompt).toContain('离线部署需要先校验安装包') expect(request.prompt).toContain('ORIGINAL_USER_REQUEST') expect(request.prompt).not.toContain('C:\\private') expect(request.trustedInstructions).toContain( 'untrusted quoted data' ) yield { requestId: request.requestId, type: 'done' } }) const retrievalResponse = { query: '如何离线部署?', durationMs: 12, settings: { version: 1 as const, topK: 6, minimumVectorSimilarity: 0, ftsWeight: 1, vectorWeight: 1, graphWeight: 0.8, candidateMultiplier: 4, contextMaxCharacters: 16_000, adjacentChunkCount: 0, localRerankEnabled: false }, diagnostics: { requestedChannels: ['fts' as const], usedChannels: ['fts' as const], degradedChannels: [], candidateCounts: { fts: 1 }, channelDurationMs: { fts: 4 }, vectorScannedCount: 0, filteredByThresholdCount: 0, filteredByBudgetCount: 0, rerank: { requested: 'none' as const, used: 'none' as const, status: 'skipped' as const, candidateCount: 1, durationMs: 0 } }, results: [ { knowledgeBaseId: libraryId, documentId: external ? undefined : documentId, sourceId: external ? undefined : '55555555-5555-4555-8555-555555555555', chunkId: external ? undefined : chunkId, external: external ? { kind: 'external' as const, provider: 'dify' as const, instanceId: libraryId, remoteKnowledgeBaseId: 'remote-kb', remoteChunkId: 'remote-chunk', location: 'Remote section 3' } : undefined, documentTitle: '离线部署.md', sourceDisplayName: '产品手册', sourceType: 'file' as const, location: '第 2 节', snippet: '离线部署需要先校验安装包', relevance: external ? undefined : 0.9, rank: 1, channels: ['fts' as const], scores: { ftsRank: 1, fusedScore: 0.8 } } ], context: { characterCount: 13, truncated: false, groups: [ { resultChunkId: chunkId, chunkIds: [chunkId], documentId, content: '离线部署需要先校验安装包', characterCount: 13, truncated: false } ] } } if (external) { retrievalResponse.context.groups = [] retrievalResponse.results.push({ ...retrievalResponse.results[0]!, rank: 2, external: { ...retrievalResponse.results[0]!.external!, remoteChunkId: 'second-chunk' } }) } const retrieveMany = vi.fn(async () => [ { knowledgeBaseId: libraryId, response: retrievalResponse } ]) const knowledgeGateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => ['knowledge_search']), drainReferences: vi.fn(() => []), revoke: vi.fn() } const harness = createHarness( { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, supportsScopedDataTools: !external, run }, undefined, 'always', undefined, false, undefined, { database: { listKnowledgeBases: vi.fn(() => [ { id: libraryId, name: '产品知识' } ]) }, retrieveMany }, knowledgeGateway ) const requestId = '00000000-0000-4000-8000-000000000024' await harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'always-retrieve', prompt: '如何离线部署?', workMode: 'ask', knowledgeLibraryIds: [libraryId], knowledgeRetrievalMode: 'always' }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect(retrieveMany).toHaveBeenCalledWith( [libraryId], '如何离线部署?', expect.any(AbortSignal) ) const publicEvents = harness.webContents.send.mock.calls .filter(([channel]) => channel === ipcChannels.agentEvent) .map(([, payload]) => payload) expect(publicEvents).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'knowledge-retrieval', state: 'searching' }), expect.objectContaining({ type: 'knowledge-retrieval', state: 'succeeded', resultCount: external ? 2 : 1 }), expect.objectContaining({ type: 'source-references', references: external ? expect.arrayContaining([ expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'remote-chunk' }) }), expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second-chunk' }) }) ]) : [expect.objectContaining({ chunkId, documentId })] }) ]) ) expect(run).toHaveBeenCalledOnce() if (external) expect(run.mock.calls[0]![0].prompt).toContain('"locator":"Remote section 3"') await harness.dispose() }) it('cancels an active full-library rebuild through its scoped controller', async () => { const libraryId = '11111111-1111-4111-8111-111111111111' let resolveRebuild: | ((value: { rebuilt: number; failed: number }) => void) | undefined const rebuildLibrary = vi.fn( () => new Promise<{ rebuilt: number; failed: number }>( (resolve) => { resolveRebuild = resolve } ) ) const cancelLibraryRebuild = vi.fn(() => true) const harness = createHarness( { capability: 'chat', supportsToolExecution: true }, undefined, 'always', undefined, false, undefined, { database: { listKnowledgeBases: vi.fn(() => []) }, cancelLibraryRebuild, rebuildLibrary } ) const event = trustedEvent(harness.webContents) const rebuild = harness.knowledgeRebuildHandler?.(event, { knowledgeBaseId: libraryId }) as Promise await vi.waitFor(() => expect(rebuildLibrary).toHaveBeenCalledOnce()) expect( await Promise.resolve( harness.knowledgeCancelRebuildHandler?.(event, libraryId) ) ).toBe(true) expect(cancelLibraryRebuild).toHaveBeenCalledWith(libraryId) resolveRebuild?.({ rebuilt: 0, failed: 0 }) await expect(rebuild).resolves.toEqual({ rebuilt: 0, failed: 0 }) await harness.dispose() }) it('returns no results for an explicitly empty knowledge search scope', async () => { const retrieveMany = vi.fn(() => { throw new Error('must not search') }) const harness = createHarness( { capability: 'chat', supportsToolExecution: true }, undefined, 'always', undefined, false, undefined, { database: { listKnowledgeBases: vi.fn(() => []) }, retrieveMany } ) await expect( harness.knowledgeSearchHandler?.( trustedEvent(harness.webContents), { libraryIds: [], query: 'anything' } ) ).resolves.toEqual([]) expect(retrieveMany).not.toHaveBeenCalled() await harness.dispose() }) it('routes external UI payloads through IPC, the production service and citation search', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-external-ipc-')) const fetcher = vi.fn(async (_url, init) => Response.json(init?.method === 'POST' ? { records: [{ score: 0.75, segment: { id: 'remote-chunk', content: 'Policy evidence', document: { id: 'remote-document', name: 'Policy' } } }] } : { data: [{ id: 'dataset', name: 'Policy' }], has_more: false })) const service = new KnowledgeService({ databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'), externalFetcher: fetcher, credentialCipher: { isAvailable: () => true, encrypt: value => Buffer.from(value), decrypt: value => value.toString() } }) await service.initialize() const harness = createHarness({ capability: 'chat' }, undefined, 'always', undefined, false, undefined, service as unknown as Record) 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 => ({ runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(async () => ({ id: 'model', label, available: true, supportsToolExecution: true })), dispose: vi.fn(async () => undefined), async *run(request: { requestId: string; conversationId: string }) { run(request) yield { requestId: request.requestId, type: 'done' } } }) const firstRuntime = createRuntime('model-one', firstRun) const secondRuntime = createRuntime('model-two', secondRun) const selectedRuntimes = { getStatus: vi.fn(async () => ({ id: 'model', label: 'model-two', available: true, supportsToolExecution: true })), getRuntime: vi.fn(async (selection: typeof firstSelection) => selection.profileId === firstProfileId ? firstRuntime : secondRuntime ), releaseConversation: vi.fn(async () => undefined) } const fallbackRuntime = { runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(async () => ({ id: 'model', label: 'fallback', available: true, supportsToolExecution: true })), run: vi.fn(), dispose: vi.fn(async () => undefined) } const harness = createHarness( fallbackRuntime, undefined, 'always', undefined, false, selectedRuntimes ) const event = trustedEvent(harness.webContents) await expect( harness.statusHandler?.(event, secondSelection) ).resolves.toEqual( expect.objectContaining({ label: 'model-two' }) ) expect(selectedRuntimes.getStatus).toHaveBeenCalledWith( secondSelection ) harness.getResolvedSettings.mockResolvedValueOnce({ provider: 'opencode', defaultModelProfileId: firstProfileId, opencodeBaseUrl: '', opencodeEmbedded: true, opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' } }) await expect( harness.statusHandler?.(event) ).resolves.toEqual( expect.objectContaining({ label: 'model-two' }) ) // 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', workMode: 'ask', runtimeSelection: firstSelection }), harness.handler?.(event, { requestId: '00000000-0000-4000-8000-000000000012', conversationId: 'conversation-two', prompt: 'second request', workMode: 'ask', runtimeSelection: secondSelection }) ]) await vi.waitFor(() => { expect(firstRun).toHaveBeenCalledWith( expect.objectContaining({ conversationId: 'conversation-one', runtimeSelection: firstSelection }) ) expect(secondRun).toHaveBeenCalledWith( expect.objectContaining({ conversationId: 'conversation-two', runtimeSelection: secondSelection }) ) }) expect(fallbackRuntime.run).not.toHaveBeenCalled() expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith( firstSelection, expect.objectContaining({ kind: 'local', rootPath: 'C:\\ProjectWorkspace' }) ) expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith( secondSelection, undefined ) await harness.dispose() }) it('resolves the latest project rule for ordinary runs and lets explicit selections override it', async () => { const requestRuntime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } } } const selectedRuntimes = { getRuntime: vi.fn(async () => requestRuntime), releaseConversation: vi.fn(async () => undefined) } const harness = createHarness(requestRuntime, undefined, 'always', undefined, false, selectedRuntimes) const projectId = '00000000-0000-4000-8000-000000000101' const 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', workMode: 'ask' }) // 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', workMode: 'ask' }) 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', workMode: 'ask' }) expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith({ provider: 'model' }, expect.anything()) await harness.dispose() }) it('rejects an active duplicate before resolving Runtime or settings again', async () => { let releaseRun!: () => void const runReleased = new Promise((resolve) => { releaseRun = resolve }) let markRunStarted!: () => void const runStarted = new Promise((resolve) => { markRunStarted = resolve }) const selectedRuntime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { markRunStarted() await runReleased yield { requestId: request.requestId, type: 'done' } } } const selectedRuntimes = { getRuntime: vi.fn(async () => selectedRuntime), getStatus: vi.fn(), releaseConversation: vi.fn(async () => undefined) } const getWebSearchCapabilityStatus = vi.fn(async () => ({ enabled: true })) const getEnabledBuiltinMcpServerIds = vi.fn(async () => []) const harness = createHarness( selectedRuntime, undefined, 'always', undefined, false, selectedRuntimes, undefined, undefined, false, undefined, { getWebSearchCapabilityStatus, getEnabledBuiltinMcpServerIds } ) const event = trustedEvent(harness.webContents) harness.getApplicationSettings.mockClear() const request = { requestId: '00000000-0000-4000-8000-000000000013', conversationId: 'duplicate-request', projectId: '00000000-0000-4000-8000-000000000101', prompt: 'run once', workMode: 'ask' as const } await harness.handler?.(event, request) await runStarted await expect(harness.handler?.(event, request)).rejects.toThrow( '请求正在执行' ) expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce() expect(harness.getApplicationSettings).toHaveBeenCalledOnce() expect(getWebSearchCapabilityStatus).toHaveBeenCalledOnce() expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledOnce() expect(harness.contextManager.enrichRequest).toHaveBeenCalledOnce() releaseRun() await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( request.requestId, 'completed' ) ) await harness.dispose() }) it('starts independent interactive request lookups concurrently after enrichment', async () => { let resolveApplicationSettings!: (value: { magicNotesEnabled: boolean }) => void const applicationSettings = new Promise<{ magicNotesEnabled: boolean }>((resolve) => { resolveApplicationSettings = resolve }) let resolveWebSearch!: (value: { enabled: boolean }) => void const webSearch = new Promise<{ enabled: boolean }>((resolve) => { resolveWebSearch = resolve }) let resolveRuntimeSettings!: (value: { workspacePath: string }) => void const runtimeSettings = new Promise<{ workspacePath: string }>((resolve) => { resolveRuntimeSettings = resolve }) let resolveBuiltinMcpServers!: (value: never[]) => void const builtinMcpServers = new Promise((resolve) => { resolveBuiltinMcpServers = resolve }) const getWebSearchCapabilityStatus = vi.fn(() => webSearch) const getEnabledBuiltinMcpServerIds = vi.fn( () => builtinMcpServers ) const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } } } const goodbuddyConfigService = { takePendingReload: vi.fn(() => 'none'), revokeRequest: vi.fn(), clear: vi.fn() } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, undefined, false, goodbuddyConfigService, { getWebSearchCapabilityStatus, getEnabledBuiltinMcpServerIds } ) harness.getApplicationSettings.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', workMode: 'ask', knowledgeLibraryIds: [] } ) await vi.waitFor(() => { expect(harness.getApplicationSettings).toHaveBeenCalledOnce() expect(getWebSearchCapabilityStatus).toHaveBeenCalledOnce() expect(harness.getResolvedSettings).toHaveBeenCalledOnce() expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledOnce() }) const enrichmentOrder = harness.contextManager.enrichRequest.mock.invocationCallOrder[0] if (enrichmentOrder === undefined) { throw new Error('Request was not enriched') } for (const lookup of [ harness.getApplicationSettings, getWebSearchCapabilityStatus, harness.getResolvedSettings, getEnabledBuiltinMcpServerIds ]) { const lookupOrder = lookup.mock.invocationCallOrder[0] if (lookupOrder === undefined) { throw new Error('Interactive request lookup did not start') } expect(enrichmentOrder).toBeLessThan(lookupOrder) } resolveApplicationSettings({ magicNotesEnabled: false }) resolveWebSearch({ enabled: false }) resolveRuntimeSettings({ workspacePath: 'C:\\Workspace' }) resolveBuiltinMcpServers([]) await expect(operation).resolves.toBeUndefined() await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) await harness.dispose() }) it('aborts active work and clears browser sessions before assistant data', async () => { const lifecycle: string[] = [] let markStarted!: () => void const started = new Promise((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('coalesces concurrent local-data clear requests', async () => { let releaseClear!: () => void const clearBlocked = new Promise((resolve) => { releaseClear = resolve }) const onBeforeClearLocalData = vi.fn(async () => { await clearBlocked }) const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn() } const harness = createHarness(runtime, onBeforeClearLocalData) const event = trustedEvent(harness.webContents) const firstClear = harness.clearHandler?.(event) const secondClear = harness.clearHandler?.(event) expect(firstClear).toBe(secondClear) await vi.waitFor(() => { expect(onBeforeClearLocalData).toHaveBeenCalledOnce() }) await expect( harness.handler?.(event, { requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', conversationId: 'conversation-during-clear', prompt: 'do not start', workMode: 'execute' }) ).rejects.toThrow('本地数据维护期间暂不接受新任务') releaseClear() await expect(firstClear).resolves.toBeUndefined() expect( harness.assistantDatabase.clearAssistantData ).toHaveBeenCalledOnce() await harness.dispose() }) it('marks a request failed when a tool fails before runtime done', async () => { const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'tool', callId: 'call-1', name: 'write', state: 'failed', summary: 'OpenCode 工具:write', error: 'write path denied' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: 'write a file', workMode: 'execute' }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'failed', 'write 工具执行失败:write path denied' ) ) expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith(requestId, 'completed') expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ requestId, type: 'error', status: 'failed', message: 'write 工具执行失败:write path denied' }) ) await harness.dispose() }) it('allows a completed request after a recoverable tool failure', async () => { const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'tool', callId: 'call-recoverable', name: '浏览器输入', state: 'recoverable', summary: '直连模型工具需要刷新后重试:浏览器输入' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: 'retry browser input', workMode: 'execute' }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith( requestId, 'failed', expect.any(String) ) await harness.dispose() }) it('does not fail a completed native subagent for its provisional Task tool', async () => { const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'tool', callId: 'call-task-1', name: 'task', state: 'running', summary: 'OpenCode 工具:task' } yield { requestId: request.requestId, type: 'subagent', childTaskId: '00000000-0000-4000-8000-000000000601', expertId: '00000000-0000-4000-8000-000000000701', expertName: 'explore', routingMode: 'native', runtimeCallId: 'call-task-1', state: 'completed', output: 'Desktop files inspected.' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: 'inspect the desktop', workMode: 'execute' }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith( requestId, 'failed', expect.any(String) ) await harness.dispose() }) it.each(['opencode', 'continue'] as const)( 'preserves read-only Ask mode at the %s Runtime boundary', async (runtimeId) => { let received: | { request: { workMode?: string } authorize: unknown } | undefined const runtime = { runtimeId, capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run( request: { requestId: string; workMode?: string }, _signal: AbortSignal, authorize: unknown ) { received = { request, authorize } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: 'run the task', workMode: 'ask' }) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) expect(received?.request.workMode).toBe('ask') expect(received?.authorize).toBeUndefined() expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect( harness.assistantDatabase.createTask ).toHaveBeenCalledWith( expect.objectContaining({ id: requestId, workMode: 'ask' }) ) await harness.dispose() } ) it('keeps Ask fail-closed and auto-allows DeepSeek Harness Execute tools', async () => { const receivedAuthorizers: unknown[] = [] const executeDecisions: string[] = [] const runtime = { runtimeId: 'deepseek-harness', capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run( request: { requestId: string; workMode?: string }, _signal: AbortSignal, authorize?: (request: { scopeKey: string title: string description: string }) => Promise ) { receivedAuthorizers.push(authorize) if (request.workMode === 'execute') { executeDecisions.push( (await authorize?.({ scopeKey: 'deepseek-harness:write_file', title: '写入文件', description: '主机工具执行' })) ?? 'missing' ) } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) harness.approvalBroker.request.mockResolvedValue('once') for (const [index, workMode] of ( ['ask', 'execute'] as const ).entries()) { const requestId = `3f496642-f47d-4e0a-8944-a32c77b0d6e${index}` harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: `conversation-${index}`, prompt: 'run the task', workMode }) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) } expect(receivedAuthorizers).toEqual([ expect.any(Function), expect.any(Function) ]) expect(executeDecisions).toEqual(['once']) await expect( ( receivedAuthorizers[0] as ( request: Record ) => Promise )({ scopeKey: 'deepseek-harness:write_file', title: '写入文件', description: 'must be denied' }) ).resolves.toBe('deny') expect(harness.approvalBroker.request).not.toHaveBeenCalled() await harness.dispose() }) it.each(['model', 'opencode'] as const)( 'normalizes legacy interactive Plan requests to Ask for %s', async (runtimeId) => { let receivedRequest: | { requestId: string; prompt: string; workMode?: string } | undefined const runtime = { runtimeId, capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run(request: { requestId: string prompt: string workMode?: string }) { receivedRequest = request yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: 'draft a plan', workMode: 'plan' }) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) expect(receivedRequest?.workMode).toBe('ask') expect(receivedRequest?.prompt).toContain('Work mode: Ask.') expect(receivedRequest?.prompt).not.toContain('Work mode: Plan.') expect( harness.assistantDatabase.createTask ).toHaveBeenCalledWith( expect.objectContaining({ id: requestId, workMode: 'ask' }) ) await harness.dispose() } ) it('sends the work-mode instruction 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', workMode: 'execute' }) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) expect(receivedRequest?.prompt).toBe('fix the bug') expect(receivedRequest?.trustedInstructions).toContain('Work mode: Execute.') 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(harness.getPolicySettings).toHaveBeenCalledOnce() expect(harness.getResolvedSettings).not.toHaveBeenCalled() expect(subagentService.run).toHaveBeenCalledWith( expect.objectContaining({ expert, routingMode: 'smart' }) ) expect(harness.assistantDatabase.appendTaskEvent).toHaveBeenCalledWith( requestId, 'subagent', expect.objectContaining({ childTaskId, state: 'queued' }) ) expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ type: 'subagent', state: 'completed' }) ) await harness.dispose() }) it.each([ { workMode: 'ask' as const, persisted: false }, { workMode: 'execute' as const, persisted: true } ])( 'falls back to the ordinary runtime for ineligible smart routing %#', async ({ workMode, persisted }) => { const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } } } const run = vi.spyOn(runtime, 'run') const subagentService = { run: vi.fn(), cancelAll: vi.fn(), dispose: vi.fn(async () => undefined) } const harness = createHarness( runtime, undefined, 'always', subagentService, persisted ) vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([ { id: '00000000-0000-4000-8000-000000000001', name: '研究专家', description: '', systemInstructions: 'Analyze.', routingKeywords: ['资料分析'], enabled: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z' } ]) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-fallback', prompt: '请做资料分析', workMode, smartRouting: true }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'completed' ) ) expect(run).toHaveBeenCalledOnce() expect(subagentService.run).not.toHaveBeenCalled() await harness.dispose() } ) it('does not fall back to the ordinary runtime after smart subagent cancellation', async () => { const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), run: vi.fn() } let markStarted!: () => void const started = new Promise((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.getPolicySettings).not.toHaveBeenCalled() expect(harness.getResolvedSettings).not.toHaveBeenCalled() expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith( expect.objectContaining({ title: '企业微信远程请求', instructions: '请只读分析', workMode: 'ask', origin: 'delegation' }) ) await harness.dispose() }) it.each([ ['weixin', '微信 ClawBot'], ['wecom', '企业微信'], ['dingtalk', '钉钉'] ] as const)( 'grants read-only Magic Notes tools to %s channel Ask requests', async (channel, channelLabel) => { let receivedRequest: | { knowledgeCapabilityToken?: string prompt: string trustedInstructions?: string workMode: string } | undefined const runtime = { capability: 'chat', async *run(request: { requestId: string knowledgeCapabilityToken?: string prompt: string trustedInstructions?: string workMode: string }) { receivedRequest = request yield { requestId: request.requestId, type: 'tool', callId: `call-${channel}-note-list`, name: 'note_list', state: 'completed', summary: '读取笔记列表' } yield { requestId: request.requestId, type: 'done' } } } const knowledgeGateway = { grant: vi.fn(() => 'channel-notes-capability' ), getAvailableToolNames: vi.fn(() => [ 'note_list', 'note_get', 'note_search' ]), drainReferences: vi.fn(() => []), revoke: vi.fn() } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, true ) vi.mocked( harness.assistantDatabase.listProjects ).mockReturnValue([ { id: '00000000-0000-4000-8000-000000000401', name: channelLabel, description: `${channelLabel}远程消息与受控任务`, rootPath: 'C:\\ProjectWorkspace', executionSpace: { kind: 'local', rootPath: 'C:\\ProjectWorkspace' }, defaultWorkMode: 'ask', runtimeSelection: { provider: 'model', 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, workMode: 'ask' }, new AbortController().signal ) ).resolves.toMatchObject({ status: 'completed' }) const requestId = vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0] expect(knowledgeGateway.grant).toHaveBeenCalledWith( requestId, [], expect.any(AbortSignal), 'read' ) expect(receivedRequest).toMatchObject({ knowledgeCapabilityToken: 'channel-notes-capability', workMode: 'ask', trustedInstructions: expect.stringContaining( 'note_list, note_get, note_search' ) }) expect(receivedRequest?.prompt).toContain('读取我的笔记') expect(knowledgeGateway.revoke).toHaveBeenCalledWith( 'channel-notes-capability' ) await harness.dispose() } ) it('persists remote media and passes it through the existing context path', async () => { let receivedRequest: | { contextIds?: string[] prompt: string } | undefined const runtime = { capability: 'chat', async *run(request: { requestId: string contextIds?: string[] prompt: string }) { receivedRequest = request yield { requestId: request.requestId, type: 'text', delta: '图片已分析' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } await expect( executor( { channel: 'wecom', eventId: 'event-media', senderId: 'user-1', conversationId: 'conversation-media', conversationType: 'direct', text: '', attachments: [ { name: '现场.png', mimeType: 'image/png', size: 4, kind: 'image', dataBase64: 'iVBORw==' } ], mentioned: false, workMode: 'ask' }, new AbortController().signal ) ).resolves.toEqual({ status: 'completed', output: '图片已分析' }) expect( harness.contextManager.ingestRemoteAttachment ).toHaveBeenCalledWith( expect.objectContaining({ name: '现场.png' }) ) expect(receivedRequest?.contextIds).toEqual([ '00000000-0000-4000-8000-000000000498' ]) expect( harness.assistantDatabase.appendRemoteConversationMessage ).toHaveBeenCalledWith( expect.objectContaining({ role: 'user', content: '请分析我发送的附件。', attachments: [ expect.objectContaining({ name: '现场.png' }) ] }) ) expect(harness.contextManager.remove).toHaveBeenCalledWith( '00000000-0000-4000-8000-000000000498' ) await harness.dispose() }) it('returns a generated image only as a current-task channel attachment', async () => { const image = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]) const runtime = { capability: 'image-generation', async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'generated-image', mimeType: 'image/png', data: image.toString('base64'), title: '结果图' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } await expect( executor( { channel: 'wecom', eventId: 'event-generated-image', senderId: 'user-1', conversationId: 'conversation-generated-image', conversationType: 'direct', text: '生成结果图', mentioned: false, workMode: 'ask' }, new AbortController().signal ) ).resolves.toMatchObject({ status: 'completed', attachments: [ { name: '结果图.png', mimeType: 'image/png', size: image.byteLength, kind: 'image', dataBase64: image.toString('base64') } ], artifactIds: [ '00000000-0000-4000-8000-000000000499' ] }) expect( harness.assistantDatabase.appendRemoteConversationMessage ).toHaveBeenLastCalledWith( expect.objectContaining({ role: 'assistant', artifactIds: [ '00000000-0000-4000-8000-000000000499' ] }) ) await harness.dispose() }) it('creates a bounded result file only when the remote user explicitly requests one', async () => { const runtime = { capability: 'chat', async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'text', delta: '# 本周报告\n\n已完成。' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } const result = await executor( { channel: 'wecom', eventId: 'event-result-file', senderId: 'user-1', conversationId: 'conversation-result-file', conversationType: 'direct', text: '请生成一个文件,总结本周进展', mentioned: false, workMode: 'ask' }, new AbortController().signal ) expect(result).toMatchObject({ status: 'completed', output: '# 本周报告\n\n已完成。', attachments: [ { name: 'GoodBuddy-结果.md', mimeType: 'text/markdown', kind: 'file' } ] }) expect( Buffer.from( result.attachments?.[0]?.dataBase64 ?? '', 'base64' ).toString('utf8') ).toBe('# 本周报告\n\n已完成。') expect( harness.assistantDatabase.createTextArtifact ).not.toHaveBeenCalled() await harness.dispose() }) it.each([ { name: 'Runtime status resolution fails', getStatus: () => Promise.reject(new Error('状态查询失败')), expectedError: '状态查询失败' }, { name: 'Runtime is available without tool execution', getStatus: () => Promise.resolve({ id: 'model', label: 'Direct model', available: true, supportsToolExecution: false }), expectedError: '所选处理后端不支持工具执行,请在消息通道设置中选择 OpenCode、Continue 或支持工具的直连模型' } ])( 'finalizes remote Execute consistently when $name', async ({ getStatus, expectedError }) => { const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, getStatus: vi.fn(getStatus), run: vi.fn() } const harness = createHarness(runtime) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } await expect( executor( { channel: 'wecom', eventId: 'event-execute-unavailable', senderId: 'user-1', conversationId: 'conversation-execute-unavailable', conversationType: 'direct', text: '/execute 更新 README', mentioned: false, workMode: 'ask' }, new AbortController().signal ) ).resolves.toEqual({ status: 'failed', error: expectedError }) const remoteTask = harness.assistantDatabase.createTask.mock.calls[0]?.[0] expect(remoteTask).toEqual( expect.objectContaining({ projectId: '00000000-0000-4000-8000-000000000401', conversationId: '00000000-0000-4000-8000-000000000402', workMode: 'execute', origin: 'delegation', visible: false }) ) expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(remoteTask?.id, 'failed', expectedError) expect( harness.assistantDatabase.appendRemoteConversationMessage ).toHaveBeenLastCalledWith({ conversationId: '00000000-0000-4000-8000-000000000402', role: 'assistant', content: expectedError, status: '执行不可用' }) const activities = harness.webContents.send.mock.calls .filter( ([channel]) => channel === ipcChannels.remoteChannelActivity ) .map(([, activity]) => activity) expect(activities).toHaveLength(2) expect(activities[0]).toEqual( expect.objectContaining({ requestId: remoteTask?.id, kind: 'request', status: 'running' }) ) expect(activities[1]).toEqual({ requestId: remoteTask?.id, conversationId: '00000000-0000-4000-8000-000000000402', projectId: '00000000-0000-4000-8000-000000000401', projectName: '企业微信', channel: 'wecom', kind: 'result', title: '企业微信远程执行不可用', detail: expectedError, status: 'failed' }) expect(runtime.run).not.toHaveBeenCalled() await harness.dispose() } ) it('runs remote Execute immediately with the selected direct model policy', async () => { let authorization: string | undefined const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, getStatus: vi.fn(async () => ({ id: 'model', label: 'Direct model', available: true, supportsToolExecution: true })), async *run( request: { requestId: string }, _signal: AbortSignal, authorize: ( request: { scopeKey: string title: string description: string } ) => Promise ) { authorization = await authorize({ scopeKey: 'model:builtin:workspace_apply_patch', title: '写入文件', description: '写入 README.md' }) yield { requestId: request.requestId, type: 'text', delta: '执行完成' } yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } const reportProgress = vi.fn(async () => undefined) await expect( executor( { channel: 'wecom', eventId: 'event-execute-direct', senderId: 'user-1', conversationId: 'conversation-execute-direct', conversationType: 'direct', text: '/execute 更新 README', mentioned: false, workMode: 'ask' }, new AbortController().signal, reportProgress ) ).resolves.toEqual({ status: 'completed', output: '执行完成' }) expect(authorization).toBe('once') expect(reportProgress).not.toHaveBeenCalled() expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith( expect.any(String), 'waiting_approval' ) // 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 Execute requests', async () => { let receivedRequest: | { knowledgeCapabilityToken?: string trustedInstructions?: string workMode: string } | undefined const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, getStatus: vi.fn(async () => ({ id: 'model', label: 'Direct model', available: true, supportsToolExecution: true })), async *run(request: { requestId: string knowledgeCapabilityToken?: string trustedInstructions?: string workMode: string }) { receivedRequest = request yield { requestId: request.requestId, type: 'done' } } } const knowledgeGateway = { grant: vi.fn(() => 'channel-notes-write-capability' ), getAvailableToolNames: vi.fn(() => [ 'note_list', 'note_get', 'note_search', 'note_create', 'note_update', 'note_entry_create', 'note_entry_update', 'note_entry_delete', 'note_delete' ]), drainReferences: vi.fn(() => []), revoke: vi.fn() } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, true ) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } await expect( executor( { channel: 'wecom', eventId: 'event-execute-notes', senderId: 'user-1', conversationId: 'conversation-execute-notes', conversationType: 'direct', text: '/execute 创建一条笔记', mentioned: false, workMode: 'ask' }, new AbortController().signal ) ).resolves.toMatchObject({ status: 'completed' }) const requestId = vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0] expect(knowledgeGateway.grant).toHaveBeenCalledWith( requestId, [], expect.any(AbortSignal), 'write' ) expect(receivedRequest).toMatchObject({ knowledgeCapabilityToken: 'channel-notes-write-capability', workMode: 'execute', trustedInstructions: expect.stringContaining('note_create') }) expect(receivedRequest?.trustedInstructions).toContain( 'note_delete' ) expect(knowledgeGateway.revoke).toHaveBeenCalledWith( 'channel-notes-write-capability' ) await harness.dispose() }) it('does not grant or advertise Magic Notes to external OpenCode channels', async () => { let receivedRequest: | { knowledgeCapabilityToken?: string trustedInstructions?: string } | undefined const selectedRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, supportsScopedDataTools: false, getStatus: vi.fn(async () => ({ id: 'opencode', label: 'External OpenCode', available: true, supportsToolExecution: true })), async *run(request: { requestId: string knowledgeCapabilityToken?: string trustedInstructions?: string }) { receivedRequest = request yield { requestId: request.requestId, type: 'done' } } } const knowledgeGateway = { grant: vi.fn(() => 'must-not-be-granted'), getAvailableToolNames: vi.fn(() => ['note_list']), drainReferences: vi.fn(() => []), revoke: vi.fn() } const harness = createHarness( selectedRuntime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, true ) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } const result = await executor( { channel: 'wecom', eventId: 'event-external-opencode', senderId: 'friend', conversationId: 'external-opencode', conversationType: 'direct', text: '读取我的笔记', mentioned: true, workMode: 'ask' }, new AbortController().signal ) if (result.status === 'failed') { throw new Error(result.error) } expect(result).toMatchObject({ status: 'completed' }) expect(knowledgeGateway.grant).not.toHaveBeenCalled() expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined() expect(receivedRequest?.trustedInstructions).not.toContain( 'note_list' ) await harness.dispose() }) it('advertises only native read access to remote Agent Runtime Ask requests', async () => { let receivedRequest: | { trustedInstructions?: string workMode?: string } | undefined const selectedRuntime = { runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string trustedInstructions?: string workMode?: string }) { receivedRequest = request yield { requestId: request.requestId, type: 'done' } } } const selectedRuntimes = { getRuntime: vi.fn(async () => selectedRuntime), getStatus: vi.fn(), releaseConversation: vi.fn(async () => undefined) } const harness = createHarness( { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run: vi.fn() }, undefined, 'always', undefined, false, selectedRuntimes ) const projectId = '00000000-0000-4000-8000-000000000451' harness.getApplicationSettings.mockResolvedValue({ magicNotesEnabled: false, remoteProjectsEnabled: true } as never) harness.assistantDatabase.getProject.mockReturnValue({ id: projectId, name: 'Remote project', description: '', rootPath: '/srv/project', executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000452', remoteRootPath: '/srv/project' }, defaultWorkMode: 'ask', runtimeSelection: { provider: 'opencode' }, kind: 'user', status: 'active', createdAt: '2026-08-25T00:00:00.000Z', updatedAt: '2026-08-25T00:00:00.000Z' } as never) await harness.handler?.( trustedEvent(harness.webContents), { requestId: '00000000-0000-4000-8000-000000000453', conversationId: 'conversation-remote-ask', projectId, prompt: 'Read marker.txt', workMode: 'ask', runtimeSelection: { provider: 'opencode' }, knowledgeLibraryIds: [] } ) expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith( { provider: 'opencode' }, expect.objectContaining({ kind: 'ssh', remoteRootPath: '/srv/project' }) ) expect(receivedRequest).toMatchObject({ workMode: 'ask' }) expect(receivedRequest?.trustedInstructions).toContain( 'only the native read tool' ) expect(receivedRequest?.trustedInstructions).toContain( 'Do not call any other tool or make changes' ) expect(receivedRequest?.trustedInstructions).not.toContain( 'Do not call tools' ) await harness.dispose() }) it('routes remote Execute to a configured Agent Runtime without a GoodBuddy approval callback', async () => { let receivedAuthorize: unknown = 'not-called' const configuredProfileId = '00000000-0000-4000-8000-000000000019' const selectedRuntime = { runtimeId: 'continue', capability: 'chat', supportsToolExecution: true, getStatus: vi.fn(async () => ({ id: 'continue', label: 'Continue', available: true, supportsToolExecution: true })), async *run( request: { requestId: string }, _signal: AbortSignal, authorize?: unknown ) { receivedAuthorize = authorize yield { requestId: request.requestId, type: 'text', delta: 'Continue 已执行' } yield { requestId: request.requestId, type: 'done' } } } const selectedRuntimes = { getRuntime: vi.fn(async () => selectedRuntime), getStatus: vi.fn(), releaseConversation: vi.fn(async () => undefined) } const harness = createHarness( { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run: vi.fn() }, undefined, 'always', undefined, false, selectedRuntimes ) harness.getResolvedSettings.mockResolvedValue({ toolApproval: 'always', subagentSmartRoutingEnabled: false, continueModelProfile: { id: configuredProfileId }, modelProfiles: [{ id: configuredProfileId, 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' }, defaultWorkMode: 'execute', runtimeSelection: { provider: 'continue' }, kind: 'channel', channel: 'wecom', status: 'active', createdAt: '2026-08-04T00:00:00.000Z', updatedAt: '2026-08-04T00:00:00.000Z' } ]) const executor = channelMocks.executor if (!executor) { throw new Error('Expected channel executor') } await expect( executor( { channel: 'wecom', eventId: 'event-execute-runtime', senderId: 'user-1', conversationId: 'conversation-execute-runtime', conversationType: 'direct', text: '更新 README', mentioned: false, workMode: 'ask' }, new AbortController().signal ) ).resolves.toEqual({ status: 'completed', output: 'Continue 已执行' }) expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith( { provider: 'continue', profileId: configuredProfileId }, expect.objectContaining({ kind: 'local', rootPath: 'C:\\ProjectWorkspace' }) ) expect( harness.assistantDatabase.getOrCreateRemoteConversation ).toHaveBeenCalledWith( expect.not.objectContaining({ runtimeSelection: expect.anything() }) ) 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.getPolicySettings).toHaveBeenCalledOnce() expect(harness.getResolvedSettings).not.toHaveBeenCalled() expect( harness.assistantDatabase.updateTaskStatus ).not.toHaveBeenCalledWith(requestId, 'waiting_approval') expect(harness.webContents.send).not.toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ type: 'approval' }) ) await harness.dispose() }) it('denies direct-model Execute tools when the deny-all policy is selected', async () => { let decision: string | undefined const runtime = { runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: true, getStatus: vi.fn(), dispose: vi.fn(), async *run( request: { requestId: string }, _signal: AbortSignal, authorize: ( request: { scopeKey: string title: string description: string } ) => Promise ) { decision = await authorize({ scopeKey: 'model:builtin:workspace_read_text', title: '允许读取工作区文本?', description: '读取 README.md' }) yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness(runtime, undefined, 'policy') const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: '读取文件', workMode: 'execute' }) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) expect(decision).toBe('deny') expect(harness.approvalBroker.request).not.toHaveBeenCalled() expect(harness.getPolicySettings).toHaveBeenCalledOnce() expect(harness.getResolvedSettings).not.toHaveBeenCalled() expect(harness.webContents.send).not.toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ type: 'approval' }) ) await harness.dispose() }) it('routes local config apply through native approval and denies policy mode', async () => { for (const [toolApproval, expectedAuthorized] of [ ['always', true], ['policy', false] ] as const) { let authorizeConfigApply: | (( event: { requestId: string planId: string summary: string risk: 'high' reload: 'after-current-request' destructive: boolean }, signal: AbortSignal ) => Promise) | undefined const requestId = `3f496642-f47d-4e0a-8944-a32c77b0d6e${expectedAuthorized ? '1' : '2'}` const knowledgeGateway = { grant: vi.fn( ( _requestId: string, _libraryIds: readonly string[], _signal: AbortSignal, _magicNotesAccess: string, config: { authorizeApply?: typeof authorizeConfigApply } ) => { authorizeConfigApply = config.authorizeApply return 'config-capability' } ), getAvailableToolNames: vi.fn(() => [ 'goodbuddy_config_capabilities', 'goodbuddy_config_get', 'goodbuddy_config_plan', 'goodbuddy_config_apply' ]), drainReferences: vi.fn(() => []), revoke: vi.fn() } const goodbuddyConfigService = { takePendingReload: vi.fn(() => 'none'), revokeRequest: vi.fn(), clear: vi.fn() } const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { const authorized = await authorizeConfigApply?.( { requestId: request.requestId, planId: '11111111-1111-4111-8111-111111111111', summary: '删除一个 MCP Server', risk: 'high', reload: 'after-current-request', destructive: true }, new AbortController().signal ) expect(authorized).toBe(expectedAuthorized) yield { requestId: request.requestId, type: 'done' } } } const harness = createHarness( runtime, undefined, toolApproval, undefined, false, undefined, undefined, knowledgeGateway, false, goodbuddyConfigService ) harness.getResolvedSettings.mockResolvedValue({ workspacePath: 'C:\\Workspace' }) harness.approvalBroker.request.mockResolvedValue('once') harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: `conversation-${requestId}`, prompt: '删除 MCP', workMode: 'execute' }) await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestId, 'completed') ) if (expectedAuthorized) { expect(harness.approvalBroker.request).toHaveBeenCalledWith( expect.objectContaining({ requestId, conversationId: `goodbuddy-config:${requestId}`, scopeKey: 'goodbuddy-config:11111111-1111-4111-8111-111111111111', title: '允许高风险 GoodBuddy 配置变更?', toolName: 'goodbuddy_config_apply', allowPermanent: false }), expect.any(AbortSignal), expect.any(Function) ) } else { expect(harness.approvalBroker.request).not.toHaveBeenCalled() } await harness.dispose() } }) it('coalesces config reload until all active requests finish', async () => { const completions = new Map void>() const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, async *run(request: { requestId: string }) { await new Promise((resolve) => { completions.set(request.requestId, resolve) }) yield { requestId: request.requestId, type: 'done' } } } const knowledgeGateway = { grant: vi.fn(() => 'config-capability'), getAvailableToolNames: vi.fn(() => [ 'goodbuddy_config_capabilities' ]), drainReferences: vi.fn(() => []), revoke: vi.fn() } const goodbuddyConfigService = { takePendingReload: vi.fn(() => 'after-current-request'), revokeRequest: vi.fn(), clear: vi.fn() } const harness = createHarness( runtime, undefined, 'always', undefined, false, undefined, undefined, knowledgeGateway, false, goodbuddyConfigService ) harness.getResolvedSettings.mockResolvedValue({ workspacePath: 'C:\\Workspace' }) const requestIds = [ '3f496642-f47d-4e0a-8944-a32c77b0d6e3', '3f496642-f47d-4e0a-8944-a32c77b0d6e4' ] for (const requestId of requestIds) { harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: `conversation-${requestId}`, prompt: '配置 Runtime', workMode: 'execute' }) } await vi.waitFor(() => expect(completions.size).toBe(2)) completions.get(requestIds[0]!)?.() await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestIds[0], 'completed') ) expect(harness.onRuntimeSettingsChanged).not.toHaveBeenCalled() completions.get(requestIds[1]!)?.() await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus ).toHaveBeenCalledWith(requestIds[1], 'completed') ) await vi.waitFor(() => expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce() ) expect(goodbuddyConfigService.takePendingReload).toHaveBeenCalledTimes( 2 ) await harness.dispose() }) it('preserves bounded runtime errors for persistence and renderer delivery', async () => { const fetchCause = Object.assign( new Error('connect ECONNREFUSED 127.0.0.1:11434'), { code: 'ECONNREFUSED', syscall: 'connect', address: '127.0.0.1', port: 11434 } ) const expectedError = [ 'fetch failed', 'cause:', 'connect ECONNREFUSED 127.0.0.1:11434', 'code: ECONNREFUSED', 'syscall: connect', 'address: 127.0.0.1', 'port: 11434' ].join('\n') const runtime = { capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose: vi.fn(), async *run() { yield* [] throw new TypeError('fetch failed', { cause: fetchCause }) } } const harness = createHarness(runtime) const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' harness.handler?.(trustedEvent(harness.webContents), { requestId, conversationId: 'conversation-1', prompt: 'ask', workMode: 'ask' }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( requestId, 'failed', expectedError ) ) expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.agentEvent, expect.objectContaining({ message: expectedError }) ) await harness.dispose() }) }) describe('registerIpcHandlers Magic Notes analysis', () => { afterEach(() => { electronMocks.handlers.clear() vi.clearAllMocks() channelMocks.stop.mockResolvedValue(undefined) }) it('persists comments and usage without exposing an analysis task', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-magic-ipc-')) const database = new AssistantDatabase( join(directory, 'assistant.sqlite') ) database.initialize('C:\\Workspace') const note = database.createMagicNote({ title: 'API 回归测试' }) const withEntry = database.createMagicNoteEntry({ noteId: note.id, content: { version: 1, ops: [{ insert: '请完成发布清单。\n' }] }, plainText: '请完成发布清单。' }) const entry = withEntry.entries[0]! const releaseConversation = vi.fn(async () => undefined) const disposeRuntime = vi.fn(async () => undefined) let analysisRequestId = '' const analysisRuntime = { releaseConversation, dispose: disposeRuntime, async *run(request: { requestId: string }) { analysisRequestId = request.requestId yield { requestId: request.requestId, type: 'model-usage', callId: 'magic-call-1', runtime: 'model', provider: 'openai', model: 'test-model', inputTokens: 20, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 } as const yield { requestId: request.requestId, type: 'text', delta: '{"comments":[{"kind":"suggestion","content":"先核对发布材料。"}]}' } as const yield { requestId: request.requestId, type: 'done' } as const } } runtimeFactoryMocks.createDefaultModelRuntime.mockReturnValue( analysisRuntime ) const webContents = { 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, { 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 }) } }) })