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