10370 lines
300 KiB
TypeScript
10370 lines
300 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
mkdtemp,
|
|
readFile,
|
|
realpath,
|
|
rm,
|
|
writeFile
|
|
} from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { ipcChannels } from '../shared/ipc-channels'
|
|
import type {
|
|
AssistantProject,
|
|
ConversationQueueItem
|
|
} from '../shared/assistant-contracts'
|
|
import type { AgentEvent, BrowserLiveState } from '../shared/contracts'
|
|
import type {
|
|
SshHostAgentConnectionStatus
|
|
} from '../shared/ssh-host-contracts'
|
|
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
|
|
import { AssistantDatabase } from './assistant/assistant-database'
|
|
import { BrowserNavigationStoppedError } from './browser/browser-service'
|
|
import {
|
|
registerIpcHandlers,
|
|
sendRemoteEnvironmentUpdateProgress,
|
|
sendRemoteProjectSaveProgress
|
|
} from './ipc'
|
|
|
|
describe('embedding IPC boundary', () => {
|
|
it('validates explicit IDs and keeps ZIP paths in the native dialog', async () => {
|
|
const source = await readFile(
|
|
join(process.cwd(), 'src', 'main', 'ipc.ts'),
|
|
'utf8'
|
|
)
|
|
const region =
|
|
source.match(
|
|
/const requireEmbeddingProvider[\s\S]*?registerHandler\(ipcChannels\.speechModelsGet/u
|
|
)?.[0] ?? ''
|
|
expect(region).toContain('embeddingConnectionIdRequestSchema.parse(input)')
|
|
expect(region).toContain('embeddingModelInstallInputSchema.parse(input)')
|
|
expect(region).toContain('embeddingModelActionInputSchema.parse(input)')
|
|
expect(region).toContain(
|
|
'embeddingModelProgressSnapshotSchema.parse'
|
|
)
|
|
expect(region).toContain(
|
|
'embeddingModelManager.getProgressSnapshot()'
|
|
)
|
|
expect(region).toContain("properties: ['openFile']")
|
|
expect(region).toContain('filters: modelArchiveDialogFilters')
|
|
expect(region).not.toMatch(
|
|
/embeddingModelsImportArchive[\s\S]{0,300}input\.(?:path|archivePath|directory)/u
|
|
)
|
|
expect(region).not.toMatch(
|
|
/(?:agent|selectedRuntimes|remoteModelGateway).{0,80}(?:embedding|vector)/iu
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('local tool environment IPC boundary', () => {
|
|
it('trust-checks every operation and accepts only shared bounded schemas', async () => {
|
|
const source = await readFile(
|
|
join(process.cwd(), 'src', 'main', 'ipc.ts'),
|
|
'utf8'
|
|
)
|
|
const region =
|
|
source.match(
|
|
/const requireLocalToolEnvironmentService[\s\S]*?registerHandler\(ipcChannels\.shortcutSettingsGet/u
|
|
)?.[0] ?? ''
|
|
expect(region.match(/assertTrustedSender\(event, window\)/gu)).toHaveLength(8)
|
|
expect(region).toContain('localToolEnvironmentSettingsSchema.parse(input)')
|
|
expect(region).toContain('localToolKindInputSchema.parse(input)')
|
|
expect(region).toContain('localToolDiagnoseInputSchema.parse(input)')
|
|
expect(region.match(/z\.undefined\(\)\.parse\(input\)/gu)).toHaveLength(5)
|
|
expect(region).not.toMatch(
|
|
/\b(?:command|args|env|url|hash|installDirectory|shimContents)\b/u
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('terminal IPC boundary', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates terminal requests and routes them through the trusted renderer owner', async () => {
|
|
const webContents = {
|
|
id: 73,
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const snapshot = {
|
|
sessionId: '00000000-0000-4000-8000-000000000073',
|
|
target: { type: 'local' as const },
|
|
title: 'PowerShell',
|
|
state: 'running' as const,
|
|
cwd: 'C:\\Workspace',
|
|
cols: 120,
|
|
rows: 36,
|
|
lastSequence: 0
|
|
}
|
|
const terminalSessionManager = {
|
|
create: vi.fn(async () => snapshot),
|
|
enableEventDelivery: vi.fn(),
|
|
write: vi.fn(),
|
|
resize: vi.fn(),
|
|
snapshot: vi.fn(() => snapshot),
|
|
acknowledge: vi.fn(),
|
|
close: vi.fn(async () => ({
|
|
...snapshot,
|
|
state: 'closed' as const
|
|
})),
|
|
closeOwner: vi.fn(async () => undefined)
|
|
}
|
|
const args: Parameters<typeof registerIpcHandlers> = [
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => {}),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
terminalSessionManager as never
|
|
]
|
|
const dispose = registerIpcHandlers(...args)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.terminalCreate)?.(event, {
|
|
target: { type: 'local' },
|
|
cols: 120,
|
|
rows: 36
|
|
})
|
|
).resolves.toEqual(snapshot)
|
|
expect(terminalSessionManager.create).toHaveBeenCalledWith(73, {
|
|
target: { type: 'local' },
|
|
cols: 120,
|
|
rows: 36
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
terminalSessionManager.enableEventDelivery
|
|
).toHaveBeenCalledWith(73, snapshot.sessionId)
|
|
)
|
|
|
|
electronMocks.handlers.get(ipcChannels.terminalWrite)?.(event, {
|
|
sessionId: snapshot.sessionId,
|
|
data: 'echo ready\r'
|
|
})
|
|
electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, {
|
|
sessionId: snapshot.sessionId,
|
|
cols: 100,
|
|
rows: 28
|
|
})
|
|
electronMocks.handlers.get(ipcChannels.terminalAck)?.(event, {
|
|
sessionId: snapshot.sessionId,
|
|
sequence: 4
|
|
})
|
|
expect(
|
|
electronMocks.handlers.get(ipcChannels.terminalSnapshot)?.(
|
|
event,
|
|
{ sessionId: snapshot.sessionId }
|
|
)
|
|
).toEqual(snapshot)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.terminalClose)?.(event, {
|
|
sessionId: snapshot.sessionId
|
|
})
|
|
).resolves.toMatchObject({ state: 'closed' })
|
|
|
|
expect(terminalSessionManager.write).toHaveBeenCalledWith(
|
|
73,
|
|
snapshot.sessionId,
|
|
'echo ready\r'
|
|
)
|
|
expect(terminalSessionManager.resize).toHaveBeenCalledWith(
|
|
73,
|
|
snapshot.sessionId,
|
|
{ cols: 100, rows: 28 }
|
|
)
|
|
expect(terminalSessionManager.acknowledge).toHaveBeenCalledWith(
|
|
73,
|
|
snapshot.sessionId,
|
|
4
|
|
)
|
|
expect(terminalSessionManager.snapshot).toHaveBeenCalledWith(
|
|
73,
|
|
snapshot.sessionId
|
|
)
|
|
expect(terminalSessionManager.close).toHaveBeenCalledWith(
|
|
73,
|
|
snapshot.sessionId
|
|
)
|
|
|
|
expect(
|
|
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.(event)
|
|
).toBe('Get-Location\r')
|
|
expect(electronMocks.readClipboardText).toHaveBeenCalledOnce()
|
|
|
|
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
|
|
event,
|
|
'## Copied response'
|
|
)
|
|
expect(electronMocks.writeClipboardText).toHaveBeenCalledWith(
|
|
'## Copied response'
|
|
)
|
|
expect(() =>
|
|
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.({
|
|
sender: { ...webContents, id: 99 },
|
|
senderFrame: webContents.mainFrame
|
|
})
|
|
).toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
expect(() =>
|
|
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
|
|
event,
|
|
{ text: 'invalid' }
|
|
)
|
|
).toThrow()
|
|
expect(() =>
|
|
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
|
|
{
|
|
sender: { ...webContents, id: 99 },
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
'untrusted'
|
|
)
|
|
).toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
|
|
expect(() =>
|
|
electronMocks.handlers.get(ipcChannels.terminalWrite)?.(
|
|
{
|
|
sender: { ...webContents, id: 99 },
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
{
|
|
sessionId: snapshot.sessionId,
|
|
data: 'untrusted'
|
|
}
|
|
)
|
|
).toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
expect(() =>
|
|
electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, {
|
|
sessionId: snapshot.sessionId,
|
|
cols: 0,
|
|
rows: 28
|
|
})
|
|
).toThrow()
|
|
|
|
await dispose()
|
|
expect(terminalSessionManager.closeOwner).toHaveBeenCalledWith(73)
|
|
})
|
|
})
|
|
|
|
type InvokeHandler = (event: unknown, input?: unknown) => unknown
|
|
type KnowledgeGrantMock = (
|
|
requestId: string,
|
|
libraryIds: readonly string[],
|
|
signal: AbortSignal,
|
|
access: 'read' | 'write'
|
|
) => string
|
|
|
|
const electronMocks = vi.hoisted(() => {
|
|
const handlers = new Map<string, InvokeHandler>()
|
|
return {
|
|
handlers,
|
|
handle: vi.fn((channel: string, handler: InvokeHandler) => {
|
|
handlers.set(channel, handler)
|
|
}),
|
|
removeHandler: vi.fn((channel: string) => {
|
|
handlers.delete(channel)
|
|
}),
|
|
showOpenDialog: vi.fn(async () => ({
|
|
canceled: true,
|
|
filePaths: [] as string[]
|
|
})),
|
|
showSaveDialog: vi.fn(async () => ({
|
|
canceled: true,
|
|
filePath: undefined as string | undefined
|
|
})),
|
|
openPath: vi.fn(async () => ''),
|
|
showItemInFolder: vi.fn(),
|
|
openExternal: vi.fn(async () => undefined),
|
|
readClipboardText: vi.fn(() => 'Get-Location\r'),
|
|
writeClipboardText: vi.fn()
|
|
}
|
|
})
|
|
|
|
const channelMocks = vi.hoisted(() => ({
|
|
executor: undefined as
|
|
| ((
|
|
message: {
|
|
channel: string
|
|
eventId: string
|
|
senderId: string
|
|
conversationId: string
|
|
conversationType: 'direct' | 'group'
|
|
text: string
|
|
mentioned: boolean
|
|
workMode: 'ask'
|
|
attachments?: Array<{
|
|
name: string
|
|
mimeType: string
|
|
size: number
|
|
kind: 'image' | 'file'
|
|
dataBase64: string
|
|
}>
|
|
attachmentError?: string
|
|
},
|
|
signal: AbortSignal,
|
|
reportProgress?: (result: {
|
|
status: string
|
|
output?: string
|
|
error?: string
|
|
}) => Promise<void>
|
|
) => Promise<{
|
|
status: string
|
|
output?: string
|
|
error?: string
|
|
attachments?: Array<{
|
|
name: string
|
|
mimeType: string
|
|
size: number
|
|
kind: 'image' | 'file'
|
|
dataBase64: string
|
|
}>
|
|
}>)
|
|
| undefined,
|
|
stop: vi.fn(async () => undefined)
|
|
}))
|
|
|
|
const runtimeFactoryMocks = vi.hoisted(() => ({
|
|
createModelProfileRuntime: vi.fn(),
|
|
createDefaultModelRuntime: vi.fn()
|
|
}))
|
|
|
|
describe('registerIpcHandlers computer capabilities', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
channelMocks.stop.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('validates computer capability requests and restricts them to the trusted renderer', async () => {
|
|
const webContents = {
|
|
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 interact = 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 releaseConversation = vi.fn(async () => {})
|
|
const selectFiles = vi.fn(
|
|
async (
|
|
_window: unknown,
|
|
onProgress: (progress: {
|
|
phase: 'parsing'
|
|
fileName: string
|
|
fileNumber: number
|
|
fileCount: number
|
|
}) => void
|
|
) => {
|
|
onProgress({
|
|
phase: 'parsing',
|
|
fileName: 'scan.pdf',
|
|
fileNumber: 1,
|
|
fileCount: 1
|
|
})
|
|
return []
|
|
}
|
|
)
|
|
let browserStateListener:
|
|
| ((state: BrowserLiveState) => void)
|
|
| undefined
|
|
const shortcutSnapshot = {
|
|
settings: {
|
|
enabled: true,
|
|
accelerator: 'Control+Alt+K'
|
|
},
|
|
defaultSettings: {
|
|
enabled: true,
|
|
accelerator: 'CommandOrControl+Shift+Space'
|
|
},
|
|
displayAccelerator: 'Ctrl + Alt + K',
|
|
registered: true,
|
|
registeredAccelerator: 'Control+Alt+K',
|
|
status: 'registered' as const
|
|
}
|
|
const shortcutSettingsService = {
|
|
getSnapshot: vi.fn(() => shortcutSnapshot),
|
|
update: vi.fn(async () => ({
|
|
ok: true as const,
|
|
snapshot: shortcutSnapshot
|
|
}))
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
capabilityService as never,
|
|
{ clear: vi.fn(), selectFiles } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged,
|
|
undefined,
|
|
{
|
|
navigate,
|
|
back,
|
|
reload,
|
|
stopLoading,
|
|
interact,
|
|
releaseConversation,
|
|
onState: (listener) => {
|
|
browserStateListener = listener
|
|
return vi.fn()
|
|
}
|
|
},
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
shortcutSettingsService as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
expect(
|
|
electronMocks.handlers.get(ipcChannels.appInfo)?.(event)
|
|
).toMatchObject({
|
|
shortcut: 'Ctrl + Alt + K',
|
|
shortcutStatus: 'registered'
|
|
})
|
|
expect(
|
|
electronMocks.handlers.get(ipcChannels.shortcutSettingsGet)?.(
|
|
event
|
|
)
|
|
).toEqual(shortcutSnapshot)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.shortcutSettingsUpdate)?.(
|
|
event,
|
|
{ enabled: true, accelerator: 'ctrl+alt+k' }
|
|
)
|
|
).resolves.toEqual({
|
|
ok: true,
|
|
snapshot: shortcutSnapshot
|
|
})
|
|
expect(shortcutSettingsService.update).toHaveBeenCalledWith({
|
|
enabled: true,
|
|
accelerator: 'Control+Alt+K'
|
|
})
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.contextSelectFiles)?.(event)
|
|
).resolves.toEqual([])
|
|
expect(selectFiles).toHaveBeenCalledWith(window, expect.any(Function))
|
|
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()
|
|
|
|
browserStateListener?.({
|
|
conversationId: 'browser-conversation',
|
|
status: 'ready',
|
|
sessionActive: true,
|
|
isLoading: false,
|
|
canGoBack: false,
|
|
frameDataUrl: 'data:image/jpeg;base64,frame',
|
|
updatedAt: 1
|
|
})
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.browserState,
|
|
expect.objectContaining({
|
|
conversationId: 'browser-conversation',
|
|
status: 'ready'
|
|
})
|
|
)
|
|
browserStateListener?.({
|
|
conversationId: 'browser-conversation',
|
|
status: 'acting',
|
|
sessionActive: true,
|
|
isLoading: false,
|
|
canGoBack: false,
|
|
frameDataUrl: 'data:image/jpeg;base64,frame',
|
|
updatedAt: 2
|
|
})
|
|
const repeatedFramePayload = webContents.send.mock.calls.at(-1)?.[1]
|
|
expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl')
|
|
browserStateListener?.({
|
|
conversationId: 'browser-conversation',
|
|
status: 'stopped',
|
|
sessionActive: false,
|
|
isLoading: false,
|
|
canGoBack: false,
|
|
updatedAt: 3
|
|
})
|
|
browserStateListener?.({
|
|
conversationId: 'browser-conversation',
|
|
status: 'ready',
|
|
sessionActive: true,
|
|
isLoading: false,
|
|
canGoBack: false,
|
|
frameDataUrl: 'data:image/jpeg;base64,frame',
|
|
updatedAt: 4
|
|
})
|
|
expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty(
|
|
'frameDataUrl',
|
|
'data:image/jpeg;base64,frame'
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserStop)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(releaseConversation).toHaveBeenCalledWith(
|
|
'browser-conversation'
|
|
)
|
|
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)
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserBack)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(back).toHaveBeenCalledWith(
|
|
'browser-conversation',
|
|
expect.any(AbortSignal)
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserReload)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(reload).toHaveBeenCalledWith(
|
|
'browser-conversation',
|
|
expect.any(AbortSignal)
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
|
|
event,
|
|
{ conversationId: 'browser-conversation' }
|
|
)
|
|
).resolves.toBeUndefined()
|
|
expect(stopLoading).toHaveBeenCalledWith('browser-conversation')
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserInteract)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(interact).toHaveBeenCalledWith(
|
|
'browser-conversation',
|
|
expect.any(AbortSignal)
|
|
)
|
|
|
|
navigate.mockRejectedValueOnce(new BrowserNavigationStoppedError())
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
|
|
conversationId: 'browser-conversation',
|
|
url: 'https://example.com/slow'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
capabilityService.getComputerCapabilityStatus.mockResolvedValueOnce({
|
|
enabled: false,
|
|
supported: true
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserBack)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(back).toHaveBeenCalledTimes(2)
|
|
capabilityService.getComputerCapabilityStatus.mockResolvedValueOnce({
|
|
enabled: true,
|
|
supported: false
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserReload)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).rejects.toThrow('不支持')
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
|
|
conversationId: 'browser-conversation',
|
|
url: 'https://example.com/',
|
|
debugger: true
|
|
})
|
|
).rejects.toThrow()
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
|
|
{
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
{ conversationId: 'browser-conversation' }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
|
|
event,
|
|
{
|
|
conversationId: 'browser-conversation',
|
|
rawElectron: true
|
|
}
|
|
)
|
|
).rejects.toThrow()
|
|
|
|
expect(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesCreateBrowserProfile
|
|
)?.(event, {
|
|
name: '工作配置',
|
|
executable: 'C:\\unsafe.exe'
|
|
})
|
|
).toThrow()
|
|
expect(capabilityService.createBrowserProfile).not.toHaveBeenCalled()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesCreateBrowserProfile
|
|
)?.(event, {
|
|
name: '工作配置'
|
|
})
|
|
).resolves.toEqual(snapshot)
|
|
expect(capabilityService.createBrowserProfile).toHaveBeenCalledWith(
|
|
'工作配置'
|
|
)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(5)
|
|
|
|
expect(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesDiagnoseComputer
|
|
)?.(
|
|
{
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
'host-browser-control'
|
|
)
|
|
).toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers update source routing', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('uses the persisted source for checks and the download page', async () => {
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const result = {
|
|
updateAvailable: true,
|
|
currentVersion: '1.0.0',
|
|
latestVersion: '1.1.0',
|
|
releaseUrl: 'https://mesalogo.github.io/goodbuddy/#download',
|
|
target: {
|
|
platform: 'windows',
|
|
arch: 'x64',
|
|
formats: ['nsis', 'portable'],
|
|
files: []
|
|
}
|
|
}
|
|
const getApplicationSettings = vi
|
|
.fn()
|
|
.mockResolvedValue({ updateSource: 'mirror' })
|
|
const versionChecker = {
|
|
check: vi.fn(async () => result)
|
|
}
|
|
const 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 } as never,
|
|
versionChecker as never
|
|
)
|
|
|
|
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()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers model download source routing', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('uses the persisted source and rejects stale renderer requests', async () => {
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const speechSnapshot = {
|
|
rootDirectory: 'C:\\models\\speech',
|
|
selectedDownloadSource: 'hugging-face',
|
|
catalog: [],
|
|
installed: [],
|
|
operations: [],
|
|
selectedModelId: null
|
|
}
|
|
const ocrSnapshot = {
|
|
settings: {},
|
|
status: {},
|
|
ocrModels: {
|
|
rootDirectory: 'C:\\models\\ocr',
|
|
selectedDownloadSource: 'hugging-face',
|
|
catalog: [],
|
|
installed: [],
|
|
operations: []
|
|
}
|
|
}
|
|
const speechModelManager = {
|
|
install: vi.fn(async () => undefined),
|
|
getSnapshot: vi.fn(async () => speechSnapshot),
|
|
getRepositoryUrl: vi.fn(
|
|
() => 'https://huggingface.co/example/speech'
|
|
)
|
|
}
|
|
const documentOcrModelManager = {
|
|
install: vi.fn(async () => undefined),
|
|
getProgressSnapshot: vi.fn(() => ({
|
|
operations: []
|
|
})),
|
|
getRepositoryUrl: vi.fn(
|
|
() => 'https://huggingface.co/example/ocr'
|
|
)
|
|
}
|
|
const documentParsingService = {
|
|
snapshot: vi.fn(async () => ocrSnapshot)
|
|
}
|
|
const applicationSettingsStore = {
|
|
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', () => ({
|
|
app: {
|
|
getName: vi.fn(() => 'GoodBuddy'),
|
|
getVersion: vi.fn(() => '0.1.0')
|
|
},
|
|
BrowserWindow: class {},
|
|
clipboard: {
|
|
readText: electronMocks.readClipboardText,
|
|
writeText: electronMocks.writeClipboardText
|
|
},
|
|
dialog: {
|
|
showOpenDialog: electronMocks.showOpenDialog,
|
|
showSaveDialog: electronMocks.showSaveDialog
|
|
},
|
|
ipcMain: {
|
|
handle: electronMocks.handle,
|
|
removeHandler: electronMocks.removeHandler
|
|
},
|
|
Notification: class {
|
|
static isSupported(): boolean {
|
|
return false
|
|
}
|
|
},
|
|
shell: {
|
|
openPath: electronMocks.openPath,
|
|
showItemInFolder: electronMocks.showItemInFolder,
|
|
openExternal: electronMocks.openExternal
|
|
}
|
|
}))
|
|
|
|
vi.mock('./assistant/heartbeat-service', () => ({
|
|
HeartbeatService: class {
|
|
async processDue(): Promise<void> {}
|
|
}
|
|
}))
|
|
|
|
vi.mock('./agent/create-runtime', () => runtimeFactoryMocks)
|
|
|
|
vi.mock('./channels/channel-env', () => ({
|
|
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 DSH runtime extensions', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates extension actions, reloads the Runtime, and trusts only the renderer', async () => {
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const snapshot = {
|
|
marketplaceEnabled: true,
|
|
catalog: [],
|
|
installed: []
|
|
}
|
|
const runtimeExtensionStore = {
|
|
getSnapshot: vi.fn(async () => snapshot),
|
|
applyWithResult: vi.fn(async () => ({
|
|
snapshot,
|
|
changed: true
|
|
}))
|
|
}
|
|
const onRuntimeSettingsChanged = vi.fn(async () => undefined)
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
runtimeExtensionStore as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const action = {
|
|
type: 'set-enabled',
|
|
extensionId: 'dsh-plugin-greet',
|
|
enabled: true
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsSnapshot
|
|
)?.(event)
|
|
).resolves.toEqual(snapshot)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsApply
|
|
)?.(event, action)
|
|
).resolves.toEqual(snapshot)
|
|
expect(
|
|
runtimeExtensionStore.applyWithResult
|
|
).toHaveBeenCalledWith(action)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
const marketplaceAction = {
|
|
type: 'set-marketplace-enabled',
|
|
enabled: false
|
|
}
|
|
runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({
|
|
snapshot: {
|
|
...snapshot,
|
|
marketplaceEnabled: false
|
|
},
|
|
changed: true
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsApply
|
|
)?.(event, marketplaceAction)
|
|
).resolves.toEqual({
|
|
...snapshot,
|
|
marketplaceEnabled: false
|
|
})
|
|
expect(
|
|
runtimeExtensionStore.applyWithResult
|
|
).toHaveBeenLastCalledWith(marketplaceAction)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({
|
|
snapshot,
|
|
changed: false
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsApply
|
|
)?.(event, action)
|
|
).resolves.toEqual(snapshot)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsApply
|
|
)?.(event, {
|
|
...action,
|
|
extensionId: 'Invalid Extension'
|
|
})
|
|
).rejects.toThrow()
|
|
expect(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsSnapshot
|
|
)?.({
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
})
|
|
).toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers SSH hosts', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates narrow host requests and restricts them to the trusted renderer', async () => {
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const hostId = '00000000-0000-4000-8000-000000000105'
|
|
const candidateId = '00000000-0000-4000-8000-000000000106'
|
|
const fingerprint = `SHA256:${'A'.repeat(43)}`
|
|
const host = {
|
|
id: hostId,
|
|
name: 'Build host',
|
|
hostname: 'build.example.com',
|
|
port: 22,
|
|
username: 'builder',
|
|
authentication: 'system-agent' as const,
|
|
credentialConfigured: true,
|
|
credentialSource: 'system-agent' as const,
|
|
hostKey: { state: 'unverified' as const, generation: 0 },
|
|
createdAt: '2026-08-01T00:00:00.000Z',
|
|
updatedAt: '2026-08-01T00:00:00.000Z'
|
|
}
|
|
const snapshot = {
|
|
hosts: [host],
|
|
secureStorageAvailable: true
|
|
}
|
|
const sshHostService = {
|
|
getSnapshot: vi.fn(async () => snapshot),
|
|
remove: vi.fn(
|
|
async (
|
|
_hostId: string,
|
|
commitLocalDeletion?: () => unknown
|
|
) => commitLocalDeletion?.()
|
|
),
|
|
inspectDraftHostKey: vi.fn(async () => ({
|
|
candidateId,
|
|
hostId,
|
|
state: 'unverified' as const,
|
|
algorithm: 'ssh-ed25519',
|
|
fingerprintSha256: fingerprint
|
|
})),
|
|
discardCandidate: vi.fn(),
|
|
validateAndSave: vi.fn(async () => ({
|
|
host,
|
|
connection: {
|
|
hostId,
|
|
connected: true as const,
|
|
latencyMs: 12,
|
|
platform: 'linux' as const,
|
|
architecture: 'x64' as const,
|
|
shell: '/bin/bash',
|
|
homeDirectory: '/home/builder',
|
|
detail: 'SSH 已连接,远端系统为 linux/x64'
|
|
}
|
|
}))
|
|
}
|
|
const deleteProjectsReferencingSshHost = vi.fn(
|
|
(): Array<{ id: string; name: string }> => []
|
|
)
|
|
const listSshHostProjectReferences = vi.fn(() => [])
|
|
const remoteProject = {
|
|
id: '00000000-0000-4000-8000-000000000107',
|
|
name: 'Remote project'
|
|
}
|
|
const remoteProjectSaveService = {
|
|
save: vi.fn(async () => remoteProject),
|
|
cancelCurrent: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const directoryListing = {
|
|
path: '/srv',
|
|
homeDirectory: '/home/builder',
|
|
parentPath: '/',
|
|
entries: [
|
|
{
|
|
name: 'project',
|
|
path: '/srv/project'
|
|
}
|
|
],
|
|
truncated: false
|
|
}
|
|
const listDirectories = vi.fn<
|
|
(
|
|
hostId: string,
|
|
path?: string,
|
|
signal?: AbortSignal
|
|
) => Promise<typeof directoryListing>
|
|
>(async () => directoryListing)
|
|
const sshHostDirectoryBrowser = { listDirectories }
|
|
const remoteEnvironment = {
|
|
hostId,
|
|
checkedAt: '2030-01-01T00:00:00.000Z',
|
|
architecture: 'x64',
|
|
agent: {
|
|
state: 'current',
|
|
expected: {
|
|
version: '0.11.1',
|
|
architecture: 'x64'
|
|
},
|
|
installed: {
|
|
version: '0.11.1',
|
|
architecture: 'x64'
|
|
}
|
|
},
|
|
runtimes: [{
|
|
runtimeId: 'opencode',
|
|
provider: 'opencode',
|
|
state: 'current',
|
|
expected: {
|
|
version: '1.18.29',
|
|
architecture: 'x64'
|
|
},
|
|
installed: {
|
|
version: '1.18.29',
|
|
architecture: 'x64'
|
|
}
|
|
}],
|
|
remoteDownload: {
|
|
available: true,
|
|
source: 'mirror',
|
|
packageSize: 1024
|
|
}
|
|
} as const
|
|
const inspectRemoteEnvironment = vi.fn(
|
|
async () => remoteEnvironment
|
|
)
|
|
const sshHostRemoteEnvironmentInspector = {
|
|
inspect: inspectRemoteEnvironment
|
|
}
|
|
const remoteEnvironmentUpdateService = {
|
|
update: vi.fn(
|
|
async (
|
|
owner: typeof webContents,
|
|
request: {
|
|
hostId: string
|
|
method: 'remote-download' | 'goodbuddy-transfer'
|
|
},
|
|
onProgress?: (progress: {
|
|
hostId: string
|
|
method: 'remote-download' | 'goodbuddy-transfer'
|
|
phase: 'verifying'
|
|
}) => void
|
|
) => {
|
|
expect(owner).toBe(webContents)
|
|
onProgress?.({
|
|
hostId: request.hostId,
|
|
method: request.method,
|
|
phase: 'verifying'
|
|
})
|
|
}
|
|
),
|
|
cancel: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const agentPackageInventory = {
|
|
checkedAt: '2030-01-01T00:00:00.000Z',
|
|
catalog: {
|
|
state: 'available',
|
|
checkedAt: '2030-01-01T00:00:00.000Z',
|
|
error: null
|
|
},
|
|
entries: [
|
|
{
|
|
platform: 'linux',
|
|
architecture: 'x64',
|
|
state: 'verified',
|
|
version: '0.11.2',
|
|
latestVersion: '0.11.4',
|
|
updateAvailable: true,
|
|
remoteRuntimeVersion: '1.18.29',
|
|
agentProtocol: { major: 2, minor: 0 }
|
|
},
|
|
{
|
|
platform: 'linux',
|
|
architecture: 'arm64',
|
|
state: 'not-downloaded',
|
|
version: null,
|
|
latestVersion: '0.11.4',
|
|
updateAvailable: false,
|
|
remoteRuntimeVersion: null,
|
|
agentProtocol: null
|
|
}
|
|
]
|
|
} as const
|
|
const agentPackageManager = {
|
|
getAllSnapshot: vi.fn(async () => agentPackageInventory),
|
|
forPlatform: vi.fn(() => agentPackageManager),
|
|
download: vi.fn(
|
|
async (
|
|
architecture: 'x64' | 'arm64',
|
|
onProgress?: (progress: {
|
|
architecture: 'x64' | 'arm64'
|
|
phase: 'downloading'
|
|
completedBytes: number
|
|
totalBytes: number
|
|
}) => void
|
|
) => {
|
|
onProgress?.({
|
|
architecture,
|
|
phase: 'downloading',
|
|
completedBytes: 1,
|
|
totalBytes: 2
|
|
})
|
|
return agentPackageInventory
|
|
}
|
|
),
|
|
importArchive: vi.fn(async () => agentPackageInventory),
|
|
getExportArchiveName: vi.fn(
|
|
async () =>
|
|
'goodbuddy-agent-0.11.2-linux-arm64.gbagent'
|
|
),
|
|
exportArchive: vi.fn(async () => undefined)
|
|
}
|
|
const selectedRuntimes = {
|
|
reset: vi.fn(async () => undefined)
|
|
}
|
|
let publishAgentConnectionStatus:
|
|
| ((status: SshHostAgentConnectionStatus) => void)
|
|
| undefined
|
|
const removeAgentConnectionStatusListener = vi.fn()
|
|
const remoteAgentConnectionManager = {
|
|
getHostConnectionState: vi.fn(() => 'ready' as const),
|
|
onHostConnectionStateChange: vi.fn(
|
|
(
|
|
listener: (status: SshHostAgentConnectionStatus) => void
|
|
) => {
|
|
publishAgentConnectionStatus = listener
|
|
return removeAgentConnectionStatusListener
|
|
}
|
|
)
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
listSshHostProjectReferences,
|
|
deleteProjectsReferencingSshHost
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined),
|
|
undefined, // onBeforeClearLocalData
|
|
undefined, // browserControl
|
|
undefined, // subagentService
|
|
undefined, // channelSettingsStore
|
|
{
|
|
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
|
|
} as never, // applicationSettingsStore
|
|
undefined, // versionChecker
|
|
undefined, // speechModelManager
|
|
undefined, // embeddingIndexCoordinator
|
|
selectedRuntimes as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
sshHostService as never,
|
|
undefined,
|
|
remoteProjectSaveService as never,
|
|
sshHostDirectoryBrowser as never,
|
|
sshHostRemoteEnvironmentInspector as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
remoteEnvironmentUpdateService as never,
|
|
agentPackageManager as never,
|
|
remoteAgentConnectionManager as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const createInput = {
|
|
name: ' Build host ',
|
|
hostname: 'build.example.com',
|
|
port: 22,
|
|
username: 'builder',
|
|
authentication: 'system-agent',
|
|
password: { action: 'clear' }
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event)
|
|
).resolves.toEqual({
|
|
...snapshot,
|
|
projectReferences: {},
|
|
agentConnectionStatusByHostId: {
|
|
[hostId]: 'ready'
|
|
}
|
|
})
|
|
expect(
|
|
remoteAgentConnectionManager.getHostConnectionState
|
|
).toHaveBeenCalledWith(hostId)
|
|
publishAgentConnectionStatus?.({
|
|
hostId,
|
|
state: 'connecting'
|
|
})
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.sshHostsAgentConnectionStatus,
|
|
{
|
|
hostId,
|
|
state: 'connecting'
|
|
}
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsAgentPackageInventory
|
|
)?.(event, { refresh: true })
|
|
).resolves.toEqual(agentPackageInventory)
|
|
expect(
|
|
agentPackageManager.getAllSnapshot
|
|
).toHaveBeenCalledWith({ refresh: true })
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsAgentPackageDownload
|
|
)?.(event, { architecture: 'x64' })
|
|
).resolves.toEqual(agentPackageInventory)
|
|
expect(agentPackageManager.download).toHaveBeenCalledWith(
|
|
'x64',
|
|
expect.any(Function)
|
|
)
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.sshHostsAgentPackageProgress,
|
|
{
|
|
architecture: 'x64',
|
|
platform: 'linux',
|
|
phase: 'downloading',
|
|
completedBytes: 1,
|
|
totalBytes: 2
|
|
}
|
|
)
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: ['C:\\transfer\\agent.gbagent']
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsAgentPackageImport
|
|
)?.(event)
|
|
).resolves.toEqual(agentPackageInventory)
|
|
expect(agentPackageManager.importArchive).toHaveBeenCalledWith(
|
|
'C:\\transfer\\agent.gbagent'
|
|
)
|
|
|
|
electronMocks.showSaveDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePath: 'C:\\transfer\\agent'
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsAgentPackageExport
|
|
)?.(event, { architecture: 'arm64' })
|
|
).resolves.toBeUndefined()
|
|
expect(agentPackageManager.exportArchive).toHaveBeenCalledWith(
|
|
'arm64',
|
|
'C:\\transfer\\agent.gbagent'
|
|
)
|
|
expect(
|
|
agentPackageManager.getExportArchiveName
|
|
).toHaveBeenCalledWith('arm64')
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsInspectDraftKey
|
|
)?.(
|
|
event,
|
|
{
|
|
hostId,
|
|
hostname: createInput.hostname,
|
|
port: createInput.port,
|
|
username: createInput.username
|
|
}
|
|
)
|
|
).resolves.toMatchObject({ fingerprintSha256: fingerprint })
|
|
expect(
|
|
sshHostService.inspectDraftHostKey
|
|
).toHaveBeenCalledWith({
|
|
hostId,
|
|
hostname: createInput.hostname,
|
|
port: createInput.port,
|
|
username: createInput.username
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsValidateAndSave
|
|
)?.(
|
|
event,
|
|
{
|
|
candidateId,
|
|
fingerprintSha256: fingerprint,
|
|
input: createInput
|
|
}
|
|
)
|
|
).resolves.toMatchObject({
|
|
host,
|
|
connection: { connected: true }
|
|
})
|
|
expect(sshHostService.validateAndSave).toHaveBeenCalledWith({
|
|
candidateId,
|
|
fingerprintSha256: fingerprint,
|
|
input: {
|
|
...createInput,
|
|
name: 'Build host'
|
|
}
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsDiscardCandidate
|
|
)?.(event, { candidateId })
|
|
).resolves.toBeUndefined()
|
|
expect(sshHostService.discardCandidate).toHaveBeenCalledWith(
|
|
candidateId
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
|
|
event,
|
|
{ hostId }
|
|
)
|
|
).resolves.toEqual({
|
|
hostId,
|
|
deletedProjects: []
|
|
})
|
|
deleteProjectsReferencingSshHost.mockReturnValue([
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000107',
|
|
name: 'Remote project'
|
|
}
|
|
])
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
|
|
event,
|
|
{ hostId }
|
|
)
|
|
).resolves.toEqual({
|
|
hostId,
|
|
deletedProjects: [
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000107',
|
|
name: 'Remote project'
|
|
}
|
|
]
|
|
})
|
|
expect(sshHostService.remove).toHaveBeenCalledTimes(2)
|
|
sshHostService.remove.mockRejectedValueOnce(
|
|
new Error('Host store unavailable')
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
|
|
event,
|
|
{ hostId }
|
|
)
|
|
).rejects.toThrow('Host store unavailable')
|
|
expect(deleteProjectsReferencingSshHost).toHaveBeenCalledTimes(2)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsRemoteEnvironment
|
|
)?.(event, { hostId })
|
|
).resolves.toEqual(remoteEnvironment)
|
|
expect(inspectRemoteEnvironment).toHaveBeenCalledWith(hostId)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsRemoteEnvironment
|
|
)?.(event, { hostId: 'not-a-uuid' })
|
|
).rejects.toThrow()
|
|
expect(inspectRemoteEnvironment).toHaveBeenCalledOnce()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsUpdateRemoteEnvironment
|
|
)?.(event, { hostId, method: 'remote-download' })
|
|
).resolves.toBeUndefined()
|
|
expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledWith(
|
|
webContents,
|
|
{ hostId, method: 'remote-download' },
|
|
expect.any(Function)
|
|
)
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.sshHostsRemoteEnvironmentUpdateProgress,
|
|
{
|
|
hostId,
|
|
method: 'remote-download',
|
|
phase: 'verifying'
|
|
}
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsUpdateRemoteEnvironment
|
|
)?.(event, {
|
|
hostId,
|
|
method: 'remote-download',
|
|
command: 'whoami'
|
|
})
|
|
).rejects.toThrow()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsUpdateRemoteEnvironment
|
|
)?.(
|
|
{ sender: {}, senderFrame: webContents.mainFrame },
|
|
{ hostId, method: 'remote-download' }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledOnce()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsCancelRemoteEnvironmentUpdate
|
|
)?.(event, { hostId })
|
|
).resolves.toBeUndefined()
|
|
expect(remoteEnvironmentUpdateService.cancel).toHaveBeenCalledWith(
|
|
webContents,
|
|
hostId
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsValidateAndSave
|
|
)?.(event, {
|
|
candidateId: 'not-a-uuid',
|
|
fingerprintSha256: fingerprint,
|
|
input: { ...createInput, command: 'whoami' }
|
|
})
|
|
).rejects.toThrow()
|
|
expect(sshHostService.validateAndSave).toHaveBeenCalledOnce()
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.({
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
})
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsAgentPackageInventory
|
|
)?.({
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
})
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
expect(
|
|
agentPackageManager.getAllSnapshot
|
|
).toHaveBeenCalledTimes(3)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsBrowseDirectories
|
|
)?.(event, { hostId, path: '/srv' })
|
|
).resolves.toEqual(directoryListing)
|
|
expect(
|
|
sshHostDirectoryBrowser.listDirectories
|
|
).toHaveBeenCalledWith(
|
|
hostId,
|
|
'/srv',
|
|
expect.any(AbortSignal)
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsBrowseDirectories
|
|
)?.(event, { hostId, path: '../escape' })
|
|
).rejects.toThrow()
|
|
expect(
|
|
sshHostDirectoryBrowser.listDirectories
|
|
).toHaveBeenCalledOnce()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsBrowseDirectories
|
|
)?.(
|
|
{ sender: {}, senderFrame: webContents.mainFrame },
|
|
{ hostId }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
|
|
let browseSignal: AbortSignal | undefined
|
|
sshHostDirectoryBrowser.listDirectories.mockImplementationOnce(
|
|
async (...args): Promise<typeof directoryListing> => {
|
|
const signal = args[2]
|
|
if (!signal) {
|
|
throw new Error('Expected directory browse AbortSignal')
|
|
}
|
|
return new Promise<typeof directoryListing>((_resolve, reject) => {
|
|
browseSignal = signal
|
|
signal.addEventListener(
|
|
'abort',
|
|
() => reject(signal.reason),
|
|
{ once: true }
|
|
)
|
|
})
|
|
}
|
|
)
|
|
const activeBrowse = electronMocks.handlers.get(
|
|
ipcChannels.sshHostsBrowseDirectories
|
|
)?.(event, { hostId })
|
|
await vi.waitFor(() =>
|
|
expect(browseSignal).toBeInstanceOf(AbortSignal)
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsCancelDirectoryBrowse
|
|
)?.(event)
|
|
).resolves.toBeUndefined()
|
|
await expect(activeBrowse).rejects.toMatchObject({
|
|
name: 'AbortError'
|
|
})
|
|
|
|
const remoteDraft = {
|
|
intent: 'create',
|
|
draft: {
|
|
name: 'Remote project',
|
|
description: '',
|
|
runtimeSelection: { provider: 'opencode' },
|
|
hostId,
|
|
remoteRootPath: '/srv/project',
|
|
defaultWorkMode: 'ask'
|
|
}
|
|
}
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectSave
|
|
)?.(event, remoteDraft)
|
|
).resolves.toEqual(remoteProject)
|
|
expect(remoteProjectSaveService.save).toHaveBeenCalledWith(
|
|
webContents,
|
|
remoteDraft
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectCancelCurrent
|
|
)?.(event)
|
|
).resolves.toBeUndefined()
|
|
expect(
|
|
remoteProjectSaveService.cancelCurrent
|
|
).toHaveBeenCalledWith(webContents)
|
|
expect(selectedRuntimes.reset).not.toHaveBeenCalled()
|
|
expect(selectedRuntimes.reset).not.toHaveBeenCalled()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectSave
|
|
)?.(
|
|
{ sender: {}, senderFrame: webContents.mainFrame },
|
|
remoteDraft
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
|
|
await dispose()
|
|
expect(removeAgentConnectionStatusListener).toHaveBeenCalledOnce()
|
|
expect(remoteProjectSaveService.dispose).toHaveBeenCalledOnce()
|
|
expect(
|
|
remoteEnvironmentUpdateService.dispose
|
|
).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('blocks disabled and unavailable preview 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 = {
|
|
get: vi.fn(async () => ({ remoteProjectsEnabled: false }))
|
|
}
|
|
const sshHostService = {
|
|
getSnapshot: vi.fn(),
|
|
remove: vi.fn(),
|
|
inspectDraftHostKey: vi.fn(),
|
|
discardCandidate: vi.fn(),
|
|
validateAndSave: vi.fn()
|
|
}
|
|
const remoteProjectSaveService = {
|
|
activate: vi.fn(),
|
|
save: vi.fn(),
|
|
cancelCurrent: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const sshHostDirectoryBrowser = {
|
|
listDirectories: vi.fn()
|
|
}
|
|
const sshHostRemoteEnvironmentInspector = {
|
|
inspect: vi.fn()
|
|
}
|
|
const remoteEnvironmentUpdateService = {
|
|
update: vi.fn(),
|
|
cancel: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const executionSpaceResolver = {
|
|
resolveProject: vi.fn()
|
|
}
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
listSshHostProjectReferences: vi.fn(() => []),
|
|
deleteProjectsReferencingSshHost: vi.fn(() => []),
|
|
getProject: vi.fn(() => remoteProject),
|
|
updateProject: vi.fn(),
|
|
setProjectArchived: vi.fn(),
|
|
deleteProject: vi.fn()
|
|
}
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
applicationSettingsStore as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
sshHostService as never,
|
|
executionSpaceResolver as never,
|
|
remoteProjectSaveService as never,
|
|
sshHostDirectoryBrowser as never,
|
|
sshHostRemoteEnvironmentInspector as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
remoteEnvironmentUpdateService as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const createInput = {
|
|
name: 'Build host',
|
|
hostname: 'build.example.com',
|
|
port: 22,
|
|
username: 'builder',
|
|
authentication: 'system-agent',
|
|
password: { action: 'clear' }
|
|
}
|
|
const remoteDraft = {
|
|
intent: 'create',
|
|
draft: {
|
|
name: remoteProject.name,
|
|
description: '',
|
|
runtimeSelection: { provider: 'opencode' },
|
|
hostId,
|
|
remoteRootPath: '/srv/project',
|
|
defaultWorkMode: 'ask'
|
|
}
|
|
}
|
|
const disabledRequests: Array<[string, unknown?]> = [
|
|
[ipcChannels.sshHostsGet],
|
|
[ipcChannels.sshHostsRemove, { hostId }],
|
|
[
|
|
ipcChannels.sshHostsInspectDraftKey,
|
|
{
|
|
hostId,
|
|
hostname: createInput.hostname,
|
|
port: createInput.port,
|
|
username: createInput.username
|
|
}
|
|
],
|
|
[ipcChannels.sshHostsDiscardCandidate, { candidateId }],
|
|
[
|
|
ipcChannels.sshHostsValidateAndSave,
|
|
{ candidateId, fingerprintSha256: fingerprint, input: createInput }
|
|
],
|
|
[ipcChannels.sshHostsRemoteEnvironment, { hostId }],
|
|
[
|
|
ipcChannels.sshHostsUpdateRemoteEnvironment,
|
|
{ hostId, method: 'remote-download' }
|
|
],
|
|
[
|
|
ipcChannels.sshHostsBrowseDirectories,
|
|
{ hostId, path: '/srv' }
|
|
],
|
|
[ipcChannels.remoteProjectSave, remoteDraft]
|
|
]
|
|
for (const [channel, input] of disabledRequests) {
|
|
await expect(
|
|
electronMocks.handlers.get(channel)?.(event, input)
|
|
).rejects.toThrow('远程项目(技术预览)未启用')
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.projectsUpdate)?.(event, {
|
|
projectId,
|
|
input: {
|
|
name: remoteProject.name,
|
|
description: '',
|
|
rootPath: remoteProject.rootPath,
|
|
defaultWorkMode: 'ask'
|
|
}
|
|
})
|
|
).rejects.toThrow('远程项目(技术预览)未启用')
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.projectsSetArchived)?.(
|
|
event,
|
|
{ projectId, archived: true }
|
|
)
|
|
).rejects.toThrow('远程项目(技术预览)未启用')
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.projectsDelete)?.(event, {
|
|
projectId,
|
|
confirmation: remoteProject.name
|
|
})
|
|
).rejects.toThrow('远程项目(技术预览)未启用')
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.(
|
|
event,
|
|
{ projectId, path: 'README.md' }
|
|
)
|
|
).rejects.toThrow('远程项目(技术预览)未启用')
|
|
|
|
expect(sshHostService.getSnapshot).not.toHaveBeenCalled()
|
|
expect(sshHostService.remove).not.toHaveBeenCalled()
|
|
expect(sshHostService.inspectDraftHostKey).not.toHaveBeenCalled()
|
|
expect(sshHostService.discardCandidate).not.toHaveBeenCalled()
|
|
expect(sshHostService.validateAndSave).not.toHaveBeenCalled()
|
|
expect(
|
|
sshHostRemoteEnvironmentInspector.inspect
|
|
).not.toHaveBeenCalled()
|
|
expect(
|
|
sshHostDirectoryBrowser.listDirectories
|
|
).not.toHaveBeenCalled()
|
|
expect(remoteProjectSaveService.activate).not.toHaveBeenCalled()
|
|
expect(remoteProjectSaveService.save).not.toHaveBeenCalled()
|
|
expect(remoteEnvironmentUpdateService.update).not.toHaveBeenCalled()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsCancelDirectoryBrowse
|
|
)?.(event)
|
|
).resolves.toBeUndefined()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsCancelRemoteEnvironmentUpdate
|
|
)?.(event, { hostId })
|
|
).resolves.toBeUndefined()
|
|
expect(remoteEnvironmentUpdateService.cancel).toHaveBeenCalledWith(
|
|
webContents,
|
|
hostId
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectCancelCurrent
|
|
)?.(event)
|
|
).resolves.toBeUndefined()
|
|
expect(remoteProjectSaveService.cancelCurrent).toHaveBeenCalledWith(
|
|
webContents
|
|
)
|
|
expect(
|
|
assistantDatabase.listSshHostProjectReferences
|
|
).not.toHaveBeenCalled()
|
|
expect(
|
|
assistantDatabase.deleteProjectsReferencingSshHost
|
|
).not.toHaveBeenCalled()
|
|
expect(assistantDatabase.updateProject).not.toHaveBeenCalled()
|
|
expect(assistantDatabase.setProjectArchived).not.toHaveBeenCalled()
|
|
expect(assistantDatabase.deleteProject).not.toHaveBeenCalled()
|
|
expect(executionSpaceResolver.resolveProject).not.toHaveBeenCalled()
|
|
|
|
applicationSettingsStore.get.mockRejectedValueOnce(
|
|
new Error('settings unavailable')
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event)
|
|
).rejects.toThrow('远程项目(技术预览)未启用')
|
|
expect(sshHostService.getSnapshot).not.toHaveBeenCalled()
|
|
|
|
await dispose()
|
|
})
|
|
|
|
it('sends save progress only to a live owning WebContents', () => {
|
|
const owner = {
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn(),
|
|
once: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const progress = { phase: 'agent' as const }
|
|
|
|
sendRemoteProjectSaveProgress(owner as never, progress)
|
|
expect(owner.send).toHaveBeenCalledWith(
|
|
ipcChannels.remoteProjectSaveProgress,
|
|
progress
|
|
)
|
|
owner.isDestroyed.mockReturnValue(true)
|
|
sendRemoteProjectSaveProgress(owner as never, progress)
|
|
expect(owner.send).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('sends validated update progress only to a live owning WebContents', () => {
|
|
const owner = {
|
|
id: 1,
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn(),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const progress = {
|
|
hostId: '00000000-0000-4000-8000-000000000105',
|
|
method: 'remote-download' as const,
|
|
phase: 'installing-runtime' as const
|
|
}
|
|
|
|
sendRemoteEnvironmentUpdateProgress(owner as never, progress)
|
|
expect(owner.send).toHaveBeenCalledWith(
|
|
ipcChannels.sshHostsRemoteEnvironmentUpdateProgress,
|
|
progress
|
|
)
|
|
expect(() =>
|
|
sendRemoteEnvironmentUpdateProgress(owner as never, {
|
|
...progress,
|
|
phase: 'uploading'
|
|
} as never)
|
|
).toThrow()
|
|
owner.isDestroyed.mockReturnValue(true)
|
|
sendRemoteEnvironmentUpdateProgress(owner as never, progress)
|
|
expect(owner.send).toHaveBeenCalledOnce()
|
|
})
|
|
})
|
|
|
|
function runtimeUpdateFixture(
|
|
workspacePath: string,
|
|
provider: 'model' | 'opencode' | 'continue' = 'model'
|
|
) {
|
|
const profileId = '00000000-0000-4000-8000-000000000095'
|
|
const profile = {
|
|
id: profileId,
|
|
name: 'Default',
|
|
baseUrl: 'https://model.example/v1',
|
|
modelName: 'model',
|
|
protocol: 'openai-chat-completions' as const,
|
|
authentication: 'api-key' as const,
|
|
imageGenerationQuality: 'auto' as const,
|
|
apiKeyConfigured: true,
|
|
credentialSource: 'encrypted' as const
|
|
}
|
|
const publicSettings = {
|
|
provider,
|
|
modelBaseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
modelProtocol: profile.protocol,
|
|
modelAuthentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: '',
|
|
continueConfigPath: '',
|
|
continueMode: 'chat' as const,
|
|
subagentSmartRoutingEnabled: false,
|
|
knowledgeEmbeddingEnabled: false,
|
|
knowledgeEmbeddingBaseUrl: 'https://embedding.example/v1',
|
|
knowledgeEmbeddingModel: 'embedding',
|
|
knowledgeEmbeddingApiKeyConfigured: false,
|
|
knowledgeEmbeddingCredentialSource: 'none' as const,
|
|
knowledgeRerankEnabled: false,
|
|
knowledgeRerankEndpoint: 'https://rerank.example/v1',
|
|
knowledgeRerankModel: 'rerank',
|
|
knowledgeRerankApiKeyConfigured: false,
|
|
knowledgeRerankCredentialSource: 'none' as const,
|
|
workspacePath,
|
|
apiKeyConfigured: true,
|
|
credentialSource: 'encrypted' as const,
|
|
modelProfiles: [profile],
|
|
defaultModelProfileId: profileId,
|
|
opencodeModelSource: { kind: 'platform' as const },
|
|
continueModelSource: { kind: 'platform' as const },
|
|
deepseekHarnessModelSource: { kind: 'platform' as const },
|
|
secureStorageAvailable: true,
|
|
toolApproval: 'always' as const
|
|
}
|
|
return {
|
|
publicSettings,
|
|
input: {
|
|
provider,
|
|
modelBaseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
modelProtocol: profile.protocol,
|
|
modelAuthentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: '',
|
|
continueConfigPath: '',
|
|
continueMode: 'chat' as const,
|
|
subagentSmartRoutingEnabled: false,
|
|
knowledgeEmbeddingEnabled: false,
|
|
knowledgeEmbeddingBaseUrl:
|
|
publicSettings.knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel:
|
|
publicSettings.knowledgeEmbeddingModel,
|
|
knowledgeRerankEnabled: false,
|
|
knowledgeRerankEndpoint:
|
|
publicSettings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel: publicSettings.knowledgeRerankModel,
|
|
workspacePath,
|
|
apiKey: { action: 'keep' as const },
|
|
modelProfiles: [
|
|
{
|
|
id: profile.id,
|
|
name: profile.name,
|
|
baseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
protocol: profile.protocol,
|
|
authentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
apiKey: { action: 'keep' as const }
|
|
}
|
|
],
|
|
defaultModelProfileId: profileId,
|
|
opencodeModelSource: { kind: 'platform' as const },
|
|
continueModelSource: { kind: 'platform' as const },
|
|
deepseekHarnessModelSource: { kind: 'platform' as const },
|
|
toolApproval: 'always' as const
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('registerIpcHandlers lifecycle tracking', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
channelMocks.stop.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('waits for a pending settings update and Runtime reload during cleanup', async () => {
|
|
let releaseUpdate!: () => void
|
|
const updateReleased = new Promise<void>((resolve) => {
|
|
releaseUpdate = resolve
|
|
})
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), 'goodbuddy-ipc-settings-')
|
|
)
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const savedSettings = {
|
|
provider: 'model',
|
|
modelBaseUrl: 'https://bigtoken.ai',
|
|
modelName: 'sonnet-5',
|
|
modelProtocol: 'anthropic-messages',
|
|
modelAuthentication: 'api-key',
|
|
imageGenerationQuality: 'auto',
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: '',
|
|
continueConfigPath: '',
|
|
continueMode: 'chat',
|
|
subagentSmartRoutingEnabled: false,
|
|
knowledgeEmbeddingEnabled: false,
|
|
knowledgeEmbeddingBaseUrl:
|
|
'http://127.0.0.1:11434/v1/embeddings',
|
|
knowledgeEmbeddingModel: 'nomic-embed-text',
|
|
knowledgeEmbeddingApiKeyConfigured: false,
|
|
knowledgeEmbeddingCredentialSource: 'none',
|
|
knowledgeRerankEnabled: false,
|
|
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
|
|
knowledgeRerankModel: 'rerank-v3.5',
|
|
knowledgeRerankApiKeyConfigured: false,
|
|
knowledgeRerankCredentialSource: 'none',
|
|
workspacePath: workspace,
|
|
apiKeyConfigured: false,
|
|
credentialSource: 'none',
|
|
modelProfiles: [],
|
|
defaultModelProfileId: '00000000-0000-4000-8000-000000000001',
|
|
opencodeModelSource: { kind: 'platform' },
|
|
continueModelSource: { kind: 'platform' },
|
|
secureStorageAvailable: true,
|
|
toolApproval: 'always'
|
|
}
|
|
const update = vi.fn(async () => {
|
|
await updateReleased
|
|
return savedSettings
|
|
})
|
|
let releaseReload!: () => void
|
|
const reloadReleased = new Promise<void>((resolve) => {
|
|
releaseReload = resolve
|
|
})
|
|
const onRuntimeSettingsChanged = vi.fn(async () => {
|
|
await reloadReleased
|
|
})
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{
|
|
captureRollback: vi.fn(async () => ({
|
|
publicSettings: savedSettings,
|
|
restore: vi.fn(async () => savedSettings)
|
|
})),
|
|
update
|
|
} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
repairConversationRuntimeSelections: vi.fn()
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const input = {
|
|
provider: savedSettings.provider,
|
|
modelBaseUrl: savedSettings.modelBaseUrl,
|
|
modelName: savedSettings.modelName,
|
|
modelProtocol: savedSettings.modelProtocol,
|
|
modelAuthentication: savedSettings.modelAuthentication,
|
|
imageGenerationQuality: savedSettings.imageGenerationQuality,
|
|
opencodeBaseUrl: savedSettings.opencodeBaseUrl,
|
|
opencodeEmbedded: savedSettings.opencodeEmbedded,
|
|
opencodeBinaryPath: savedSettings.opencodeBinaryPath,
|
|
opencodeConfigPath: savedSettings.opencodeConfigPath,
|
|
continueBinaryPath: savedSettings.continueBinaryPath,
|
|
continueConfigPath: savedSettings.continueConfigPath,
|
|
continueMode: savedSettings.continueMode,
|
|
knowledgeEmbeddingEnabled:
|
|
savedSettings.knowledgeEmbeddingEnabled,
|
|
knowledgeEmbeddingBaseUrl:
|
|
savedSettings.knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel: savedSettings.knowledgeEmbeddingModel,
|
|
knowledgeRerankEnabled: savedSettings.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint: savedSettings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel: savedSettings.knowledgeRerankModel,
|
|
workspacePath: savedSettings.workspacePath,
|
|
apiKey: { action: 'keep' as const },
|
|
toolApproval: savedSettings.toolApproval
|
|
}
|
|
|
|
try {
|
|
const pendingUpdate = Promise.resolve(
|
|
electronMocks.handlers.get(ipcChannels.runtimeSettingsUpdate)?.(
|
|
event,
|
|
input
|
|
)
|
|
)
|
|
await vi.waitFor(() => expect(update).toHaveBeenCalledOnce())
|
|
|
|
let cleanupComplete = false
|
|
const cleanup = dispose().then(() => {
|
|
cleanupComplete = true
|
|
})
|
|
await Promise.resolve()
|
|
expect(cleanupComplete).toBe(false)
|
|
|
|
releaseUpdate()
|
|
await vi.waitFor(() =>
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
)
|
|
expect(cleanupComplete).toBe(false)
|
|
|
|
releaseReload()
|
|
await expect(pendingUpdate).resolves.toBe(savedSettings)
|
|
await cleanup
|
|
expect(cleanupComplete).toBe(true)
|
|
} finally {
|
|
releaseUpdate()
|
|
releaseReload()
|
|
await rm(workspace, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('restores settings and preserves activation plus rollback failures', async () => {
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), 'goodbuddy-ipc-settings-rollback-')
|
|
)
|
|
const profileId = '00000000-0000-4000-8000-000000000091'
|
|
const profile = {
|
|
id: profileId,
|
|
name: 'Default',
|
|
baseUrl: 'https://model.example/v1',
|
|
modelName: 'model',
|
|
protocol: 'openai-chat-completions' as const,
|
|
authentication: 'api-key' as const,
|
|
imageGenerationQuality: 'auto' as const,
|
|
apiKeyConfigured: true,
|
|
credentialSource: 'encrypted' as const
|
|
}
|
|
const previousSettings = {
|
|
provider: 'model' as const,
|
|
modelBaseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
modelProtocol: profile.protocol,
|
|
modelAuthentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: '',
|
|
continueConfigPath: '',
|
|
continueMode: 'chat' as const,
|
|
subagentSmartRoutingEnabled: false,
|
|
knowledgeEmbeddingEnabled: false,
|
|
knowledgeEmbeddingBaseUrl: 'https://embedding.example/v1',
|
|
knowledgeEmbeddingModel: 'embedding',
|
|
knowledgeEmbeddingApiKeyConfigured: false,
|
|
knowledgeEmbeddingCredentialSource: 'none' as const,
|
|
knowledgeRerankEnabled: false,
|
|
knowledgeRerankEndpoint: 'https://rerank.example/v1',
|
|
knowledgeRerankModel: 'rerank',
|
|
knowledgeRerankApiKeyConfigured: false,
|
|
knowledgeRerankCredentialSource: 'none' as const,
|
|
workspacePath: workspace,
|
|
apiKeyConfigured: true,
|
|
credentialSource: 'encrypted' as const,
|
|
modelProfiles: [profile],
|
|
defaultModelProfileId: profileId,
|
|
opencodeModelSource: { kind: 'platform' as const },
|
|
continueModelSource: { kind: 'platform' as const },
|
|
deepseekHarnessModelSource: { kind: 'platform' as const },
|
|
secureStorageAvailable: true,
|
|
toolApproval: 'always' as const
|
|
}
|
|
const candidateSettings = {
|
|
...previousSettings,
|
|
provider: 'opencode' as const
|
|
}
|
|
const update = vi.fn().mockResolvedValue(candidateSettings)
|
|
const restore = vi.fn(async () => previousSettings)
|
|
const activationError = new Error('candidate activation failed')
|
|
const rollbackError = new Error('rollback activation failed')
|
|
const onRuntimeSettingsChanged = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(activationError)
|
|
.mockRejectedValueOnce(rollbackError)
|
|
const repairs = vi.fn()
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{
|
|
captureRollback: vi.fn(async () => ({
|
|
publicSettings: previousSettings,
|
|
restore
|
|
})),
|
|
update
|
|
} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
repairConversationRuntimeSelections: repairs
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const input = {
|
|
provider: candidateSettings.provider,
|
|
modelBaseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
modelProtocol: profile.protocol,
|
|
modelAuthentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: '',
|
|
continueConfigPath: '',
|
|
continueMode: 'chat',
|
|
subagentSmartRoutingEnabled: false,
|
|
knowledgeEmbeddingEnabled: false,
|
|
knowledgeEmbeddingBaseUrl:
|
|
previousSettings.knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel:
|
|
previousSettings.knowledgeEmbeddingModel,
|
|
knowledgeRerankEnabled: false,
|
|
knowledgeRerankEndpoint:
|
|
previousSettings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel:
|
|
previousSettings.knowledgeRerankModel,
|
|
workspacePath: workspace,
|
|
apiKey: { action: 'keep' },
|
|
modelProfiles: [
|
|
{
|
|
id: profile.id,
|
|
name: profile.name,
|
|
baseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
protocol: profile.protocol,
|
|
authentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
apiKey: { action: 'keep' }
|
|
}
|
|
],
|
|
defaultModelProfileId: profileId,
|
|
opencodeModelSource: { kind: 'platform' },
|
|
continueModelSource: { kind: 'platform' },
|
|
deepseekHarnessModelSource: { kind: 'platform' },
|
|
toolApproval: 'always'
|
|
}
|
|
|
|
try {
|
|
const result = Promise.resolve(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsUpdate
|
|
)?.(event, input)
|
|
)
|
|
await expect(result).rejects.toMatchObject({
|
|
errors: [activationError, rollbackError]
|
|
})
|
|
expect(update).toHaveBeenCalledOnce()
|
|
expect(restore).toHaveBeenCalledOnce()
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(2)
|
|
expect(repairs).not.toHaveBeenCalled()
|
|
} finally {
|
|
await dispose()
|
|
await rm(workspace, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('serializes failed settings, customization, and a later successful candidate', async () => {
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), 'goodbuddy-ipc-settings-overlap-')
|
|
)
|
|
const initial = runtimeUpdateFixture(workspace, 'model')
|
|
const candidateA = runtimeUpdateFixture(workspace, 'opencode')
|
|
const candidateB = runtimeUpdateFixture(workspace, 'continue')
|
|
let currentSettings = initial.publicSettings
|
|
const initialCustomization = {
|
|
opencode: {},
|
|
continue: { presets: [] }
|
|
}
|
|
const candidateCustomization = {
|
|
opencode: { defaultAgent: 'candidate-agent' },
|
|
continue: { presets: [] }
|
|
}
|
|
let currentCustomization = initialCustomization
|
|
const captureRollback = vi.fn(async () => {
|
|
const snapshot = currentSettings
|
|
return {
|
|
publicSettings: snapshot,
|
|
restore: vi.fn(async () => {
|
|
currentSettings = snapshot
|
|
return snapshot
|
|
})
|
|
}
|
|
})
|
|
const update = vi.fn(
|
|
async (input: { provider: 'model' | 'opencode' | 'continue' }) => {
|
|
currentSettings =
|
|
input.provider === 'opencode'
|
|
? candidateA.publicSettings
|
|
: candidateB.publicSettings
|
|
return currentSettings
|
|
}
|
|
)
|
|
const updateRuntimeCustomization = vi.fn(
|
|
async (settings: typeof candidateCustomization) => {
|
|
currentCustomization = settings
|
|
return settings
|
|
}
|
|
)
|
|
let releaseCandidateA!: () => void
|
|
const candidateABlocked = new Promise<void>((resolve) => {
|
|
releaseCandidateA = resolve
|
|
})
|
|
const candidateAError = new Error('candidate A activation failed')
|
|
let activationCount = 0
|
|
const onRuntimeSettingsChanged = vi.fn(async () => {
|
|
activationCount += 1
|
|
if (activationCount === 1) {
|
|
await candidateABlocked
|
|
throw candidateAError
|
|
}
|
|
})
|
|
const repairs = vi.fn(() => [])
|
|
const reportRepairs = vi.fn()
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{
|
|
captureRollback,
|
|
update,
|
|
getRuntimeCustomization: vi.fn(
|
|
async () => currentCustomization
|
|
),
|
|
updateRuntimeCustomization
|
|
} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
repairConversationRuntimeSelections: repairs
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
reportRuntimeSelectionRepairs: reportRepairs
|
|
} as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
try {
|
|
const updateA = Promise.resolve(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsUpdate
|
|
)?.(event, candidateA.input)
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
)
|
|
const customizationUpdate = Promise.resolve(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationUpdate
|
|
)?.(event, candidateCustomization)
|
|
)
|
|
const updateB = Promise.resolve(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsUpdate
|
|
)?.(event, candidateB.input)
|
|
)
|
|
await Promise.resolve()
|
|
|
|
expect(captureRollback).toHaveBeenCalledOnce()
|
|
expect(update).toHaveBeenCalledOnce()
|
|
|
|
releaseCandidateA()
|
|
await expect(updateA).rejects.toBe(candidateAError)
|
|
await expect(customizationUpdate).resolves.toEqual(
|
|
candidateCustomization
|
|
)
|
|
await expect(updateB).resolves.toMatchObject({
|
|
provider: 'continue'
|
|
})
|
|
|
|
expect(currentSettings.provider).toBe('continue')
|
|
expect(currentCustomization).toEqual(candidateCustomization)
|
|
expect(captureRollback).toHaveBeenCalledTimes(2)
|
|
expect(update).toHaveBeenCalledTimes(2)
|
|
expect(updateRuntimeCustomization).toHaveBeenCalledOnce()
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(4)
|
|
expect(repairs).toHaveBeenCalledOnce()
|
|
expect(repairs).toHaveBeenCalledWith(
|
|
expect.objectContaining({ provider: 'continue' })
|
|
)
|
|
expect(reportRepairs).toHaveBeenCalledOnce()
|
|
} finally {
|
|
releaseCandidateA()
|
|
await dispose()
|
|
await rm(workspace, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers knowledge snapshot ontology', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('exposes per-library ontology settings and rebuild state', async () => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
const knowledgeService = {
|
|
snapshot: vi.fn(() => ({
|
|
libraries: [
|
|
{
|
|
id: libraryId,
|
|
name: 'Ontology',
|
|
description: '',
|
|
storageMode: 'reference',
|
|
graphEnabled: true,
|
|
graphStrategy: 'rules',
|
|
sourceCount: 0,
|
|
documentCount: 0,
|
|
indexedDocumentCount: 0,
|
|
retrievalSettings: {},
|
|
chunkingSettings: {},
|
|
chunkingRebuildRequired: false,
|
|
ontologySettings: defaultKnowledgeOntologySettings,
|
|
ontologyRebuildRequired: true,
|
|
updatedAt: '2026-08-12T00:00:00.000Z'
|
|
}
|
|
],
|
|
sources: [],
|
|
documents: [],
|
|
entities: [],
|
|
relations: [],
|
|
evidence: [],
|
|
tasks: []
|
|
}))
|
|
}
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
knowledgeService as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined)
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
expect(
|
|
electronMocks.handlers.get(ipcChannels.knowledgeSnapshot)?.(
|
|
event,
|
|
libraryId
|
|
)
|
|
).toMatchObject({
|
|
libraries: [
|
|
{
|
|
id: libraryId,
|
|
ontologySettings: defaultKnowledgeOntologySettings,
|
|
ontologyRebuildRequired: true
|
|
}
|
|
]
|
|
})
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers knowledge embedding index', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates and forwards library-scoped index actions', async () => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
const jobId = '22222222-2222-4222-8222-222222222222'
|
|
const snapshot = {
|
|
knowledgeBaseId: libraryId,
|
|
enabled: true,
|
|
configuration: {
|
|
provider: 'openai-compatible',
|
|
model: 'embed-v1',
|
|
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
|
|
credentialConfigured: false
|
|
},
|
|
coverage: { total: 2, indexed: 1, missing: 1, error: 0 },
|
|
indexStatus: { job: null }
|
|
}
|
|
const knowledgeService = {
|
|
getEmbeddingIndexSnapshot: vi.fn(async () => snapshot),
|
|
rebuildEmbeddingIndex: vi.fn(async () => snapshot),
|
|
cancelEmbeddingIndex: vi.fn(async () => true)
|
|
}
|
|
const settingsStore = {
|
|
getResolvedSettings: vi.fn(async () => ({
|
|
knowledgeEmbeddingEnabled: true
|
|
})),
|
|
getPublicSettings: vi.fn(async () => ({
|
|
knowledgeEmbeddingModel: 'embed-v1',
|
|
knowledgeEmbeddingBaseUrl:
|
|
'http://127.0.0.1:11434/v1/embeddings',
|
|
knowledgeEmbeddingApiKeyConfigured: false
|
|
}))
|
|
}
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
settingsStore as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
knowledgeService as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined)
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeEmbeddingIndexGet
|
|
)?.(event, { knowledgeBaseId: libraryId })
|
|
).resolves.toEqual(snapshot)
|
|
expect(
|
|
knowledgeService.getEmbeddingIndexSnapshot
|
|
).toHaveBeenCalledWith(
|
|
libraryId,
|
|
snapshot.configuration
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeEmbeddingIndexRebuild
|
|
)?.(event, { knowledgeBaseId: libraryId })
|
|
).resolves.toEqual(snapshot)
|
|
expect(knowledgeService.rebuildEmbeddingIndex).toHaveBeenCalledWith(
|
|
libraryId,
|
|
snapshot.configuration
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeEmbeddingIndexCancel
|
|
)?.(event, { knowledgeBaseId: libraryId, jobId })
|
|
).resolves.toBe(true)
|
|
expect(knowledgeService.cancelEmbeddingIndex).toHaveBeenCalledWith(
|
|
libraryId,
|
|
jobId
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeEmbeddingIndexGet
|
|
)?.(event, { knowledgeBaseId: 'not-a-uuid' })
|
|
).rejects.toThrow()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeEmbeddingIndexGet
|
|
)?.(
|
|
{ sender: {}, senderFrame: webContents.mainFrame },
|
|
{ knowledgeBaseId: libraryId }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers knowledge task actions', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates and forwards bounded cancel and retry actions', async () => {
|
|
const taskId = '33333333-3333-4333-8333-333333333333'
|
|
const knowledgeService = {
|
|
cancelTask: vi.fn(async () => true),
|
|
retryTask: vi.fn(async () => undefined)
|
|
}
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
knowledgeService as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined)
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeTaskCancel
|
|
)?.(event, { taskId })
|
|
).resolves.toBe(true)
|
|
expect(knowledgeService.cancelTask).toHaveBeenCalledWith(taskId)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeTaskRetry
|
|
)?.(event, { taskId })
|
|
).resolves.toBeUndefined()
|
|
expect(knowledgeService.retryTask).toHaveBeenCalledWith(taskId)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeTaskCancel
|
|
)?.(event, { taskId: 'not-a-uuid' })
|
|
).rejects.toThrow()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeTaskRetry
|
|
)?.(
|
|
{ sender: {}, senderFrame: webContents.mainFrame },
|
|
{ taskId }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers model ZIP dialogs', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('imports and exports speech and OCR ZIPs through trusted dialogs', async () => {
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const speechSnapshot = {
|
|
catalog: [],
|
|
installed: [],
|
|
operations: []
|
|
}
|
|
const speechModelManager = {
|
|
rootDirectory: 'C:\\models\\speech',
|
|
importArchive: vi.fn(async () => speechSnapshot),
|
|
exportArchive: vi.fn(async () => undefined),
|
|
getSnapshot: vi.fn(async () => speechSnapshot),
|
|
cancel: vi.fn()
|
|
}
|
|
const ocrSnapshot = {
|
|
settings: {},
|
|
models: {
|
|
catalog: [],
|
|
installed: [],
|
|
operations: []
|
|
}
|
|
}
|
|
const documentParsingService = {
|
|
snapshot: vi.fn(async () => ocrSnapshot)
|
|
}
|
|
const documentOcrModelManager = {
|
|
importArchive: vi.fn(async () => undefined),
|
|
exportArchive: vi.fn(async () => undefined)
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
speechModelManager as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
documentParsingService as never,
|
|
documentOcrModelManager as never
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.speechModelsImportArchive
|
|
)?.(event, { modelId: 'speech-model' })
|
|
).resolves.toBeUndefined()
|
|
expect(speechModelManager.importArchive).not.toHaveBeenCalled()
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: ['C:\\transfer\\speech.zip']
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.speechModelsImportArchive
|
|
)?.(event, { modelId: 'speech-model' })
|
|
).resolves.toBe(speechSnapshot)
|
|
expect(electronMocks.showOpenDialog).toHaveBeenLastCalledWith(
|
|
window,
|
|
expect.objectContaining({
|
|
properties: ['openFile'],
|
|
filters: [
|
|
{
|
|
name: 'GoodBuddy 模型 ZIP',
|
|
extensions: ['zip']
|
|
}
|
|
]
|
|
})
|
|
)
|
|
expect(speechModelManager.importArchive).toHaveBeenCalledWith(
|
|
'speech-model',
|
|
'C:\\transfer\\speech.zip'
|
|
)
|
|
|
|
electronMocks.showSaveDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePath: 'C:\\transfer\\speech-model'
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.speechModelsExportArchive
|
|
)?.(event, { modelId: 'speech-model' })
|
|
).resolves.toBe(speechSnapshot)
|
|
expect(speechModelManager.exportArchive).toHaveBeenCalledWith(
|
|
'speech-model',
|
|
'C:\\transfer\\speech-model.zip'
|
|
)
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: ['C:\\transfer\\ocr.zip']
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentOcrModelsImportArchive
|
|
)?.(event, { modelId: 'ocr-model' })
|
|
).resolves.toBe(ocrSnapshot)
|
|
expect(documentOcrModelManager.importArchive).toHaveBeenCalledWith(
|
|
'ocr-model',
|
|
'C:\\transfer\\ocr.zip'
|
|
)
|
|
|
|
electronMocks.showSaveDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePath: 'C:\\transfer\\ocr-model.ZIP'
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentOcrModelsExportArchive
|
|
)?.(event, { modelId: 'ocr-model' })
|
|
).resolves.toBe(ocrSnapshot)
|
|
expect(documentOcrModelManager.exportArchive).toHaveBeenCalledWith(
|
|
'ocr-model',
|
|
'C:\\transfer\\ocr-model.ZIP'
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.speechModelsExportArchive
|
|
)?.(
|
|
{
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
{ modelId: 'speech-model' }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentOcrModelsImportArchive
|
|
)?.(event, {})
|
|
).rejects.toThrow()
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers document parsing', () => {
|
|
const temporaryDirectories: string[] = []
|
|
|
|
afterEach(async () => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) =>
|
|
rm(directory, { recursive: true, force: true })
|
|
)
|
|
)
|
|
})
|
|
|
|
it('tests an explicit scenario and parses artifact PDFs through the shared service', async () => {
|
|
const directory = await mkdtemp(
|
|
join(tmpdir(), 'goodbuddy-ipc-document-parsing-')
|
|
)
|
|
temporaryDirectories.push(directory)
|
|
const diagnosticPath = join(directory, 'diagnostic.pdf')
|
|
const artifactPath = join(directory, 'artifact.pdf')
|
|
const oversizedArtifactPath = join(directory, 'oversized.pdf')
|
|
await writeFile(diagnosticPath, 'diagnostic')
|
|
await writeFile(artifactPath, 'artifact')
|
|
await writeFile(
|
|
oversizedArtifactPath,
|
|
Buffer.alloc(20 * 1024 * 1024 + 1)
|
|
)
|
|
const diagnostic = {
|
|
fileName: 'diagnostic.pdf',
|
|
sourceFormat: 'PDF',
|
|
pageCount: 1,
|
|
ocrPageCount: 0,
|
|
characterCount: 4,
|
|
method: 'native',
|
|
durationMs: 1,
|
|
preview: 'text',
|
|
warnings: []
|
|
}
|
|
const documentParsingService = {
|
|
diagnose: vi.fn(async () => diagnostic),
|
|
parse: vi.fn(async () => ({
|
|
title: 'artifact',
|
|
sourceFormat: '.pdf',
|
|
content: 'parsed artifact',
|
|
sections: [
|
|
{
|
|
locator: '第 1 页',
|
|
content: 'parsed artifact',
|
|
method: 'native'
|
|
}
|
|
],
|
|
pageCount: 1,
|
|
warnings: []
|
|
}))
|
|
}
|
|
const createInlineArtifact = vi.fn((input) => input)
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
createInlineArtifact
|
|
}
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
documentParsingService as never
|
|
)
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: [diagnosticPath]
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentParsingTest
|
|
)?.(event, { purpose: 'knowledge-index' })
|
|
).resolves.toBe(diagnostic)
|
|
expect(documentParsingService.diagnose).toHaveBeenCalledWith(
|
|
'diagnostic.pdf',
|
|
expect.any(Buffer),
|
|
'knowledge-index'
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentParsingTest
|
|
)?.(event, { purpose: 'diagnostic' })
|
|
).rejects.toThrow()
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: [artifactPath]
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.artifactsImportFiles
|
|
)?.(event)
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
title: 'artifact.pdf',
|
|
content: '## 第 1 页\n\nparsed artifact'
|
|
})
|
|
])
|
|
expect(documentParsingService.parse).toHaveBeenCalledWith(
|
|
'artifact.pdf',
|
|
expect.any(Buffer),
|
|
'artifact-import'
|
|
)
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: [oversizedArtifactPath]
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.artifactsImportFiles
|
|
)?.(event)
|
|
).rejects.toThrow('超过大小限制')
|
|
expect(documentParsingService.parse).toHaveBeenCalledOnce()
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers connection tests', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('tests a resolved model profile without invoking the selected Continue Runtime', async () => {
|
|
const profileId = '00000000-0000-4000-8000-000000000001'
|
|
const profile = {
|
|
id: profileId,
|
|
name: '默认模型',
|
|
baseUrl: 'https://models.example',
|
|
modelName: 'good-model',
|
|
protocol: 'anthropic-messages',
|
|
authentication: 'api-key',
|
|
imageGenerationQuality: 'auto',
|
|
apiKey: 'main-only-secret' as string | undefined
|
|
}
|
|
const resolvedSettings = {
|
|
provider: 'continue',
|
|
workspacePath: 'C:\\Workspace',
|
|
modelProfiles: [profile],
|
|
defaultModelProfileId: profileId
|
|
}
|
|
const modelRuntime = {
|
|
testConnection: vi.fn(async () => ({
|
|
id: 'model',
|
|
label: 'good-model',
|
|
available: true,
|
|
supportsToolExecution: true,
|
|
detail: 'Ready'
|
|
})),
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(
|
|
modelRuntime
|
|
)
|
|
const continueRuntime = {
|
|
testConnection: vi.fn(async () => {
|
|
throw new Error('Continue 配置不可用')
|
|
}),
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const getResolvedSettings = vi.fn(async () => resolvedSettings)
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const contextManager = { clear: vi.fn() }
|
|
const approvalBroker = { clear: vi.fn() }
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
continueRuntime as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{ getResolvedSettings } as never,
|
|
{} as never,
|
|
contextManager as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
approvalBroker as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsTestModel
|
|
)?.(event, profileId)
|
|
).resolves.toMatchObject({
|
|
id: 'model',
|
|
label: 'good-model',
|
|
available: true
|
|
})
|
|
expect(getResolvedSettings).toHaveBeenCalledOnce()
|
|
expect(
|
|
runtimeFactoryMocks.createModelProfileRuntime
|
|
).toHaveBeenCalledWith('C:\\Workspace', resolvedSettings, profile)
|
|
expect(modelRuntime.testConnection).toHaveBeenCalledOnce()
|
|
expect(modelRuntime.dispose).toHaveBeenCalledOnce()
|
|
expect(continueRuntime.testConnection).not.toHaveBeenCalled()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsTestModel
|
|
)?.(event, 'not-a-profile-id')
|
|
).rejects.toThrow()
|
|
expect(getResolvedSettings).toHaveBeenCalledOnce()
|
|
|
|
getResolvedSettings.mockResolvedValueOnce({
|
|
...resolvedSettings,
|
|
modelProfiles: [{ ...profile, apiKey: undefined }]
|
|
})
|
|
runtimeFactoryMocks.createModelProfileRuntime.mockClear()
|
|
modelRuntime.testConnection.mockClear()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsTestModel
|
|
)?.(event, profileId)
|
|
).rejects.toThrow('模型连接“默认模型”未配置 API Key')
|
|
expect(getResolvedSettings).toHaveBeenCalledTimes(2)
|
|
expect(
|
|
runtimeFactoryMocks.createModelProfileRuntime
|
|
).not.toHaveBeenCalled()
|
|
expect(modelRuntime.testConnection).not.toHaveBeenCalled()
|
|
|
|
const noAuthProfile = {
|
|
...profile,
|
|
authentication: 'none',
|
|
apiKey: undefined
|
|
}
|
|
const noAuthSettings = {
|
|
...resolvedSettings,
|
|
modelProfiles: [noAuthProfile]
|
|
}
|
|
getResolvedSettings.mockResolvedValueOnce(noAuthSettings)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsTestModel
|
|
)?.(event, profileId)
|
|
).resolves.toMatchObject({ available: true })
|
|
expect(
|
|
runtimeFactoryMocks.createModelProfileRuntime
|
|
).toHaveBeenCalledWith(
|
|
'C:\\Workspace',
|
|
noAuthSettings,
|
|
noAuthProfile
|
|
)
|
|
expect(modelRuntime.testConnection).toHaveBeenCalledOnce()
|
|
|
|
await dispose()
|
|
})
|
|
|
|
it('validates and tests the selected OpenCode or Continue Runtime', async () => {
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(),
|
|
getStatus: vi.fn(),
|
|
testStatus: vi.fn(async () => ({
|
|
id: 'opencode',
|
|
label: 'OpenCode',
|
|
available: true,
|
|
supportsToolExecution: true,
|
|
detail: 'Ready'
|
|
})),
|
|
releaseConversation: vi.fn(async () => undefined)
|
|
}
|
|
const fallbackRuntime = {
|
|
testConnection: vi.fn(async () => {
|
|
throw new Error('不应测试旧的全局 Runtime')
|
|
}),
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
fallbackRuntime as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => {}),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
selectedRuntimes as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const selection = {
|
|
provider: 'opencode' as const,
|
|
profileId: '00000000-0000-4000-8000-000000000001'
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.runtimeSettingsTest)?.(
|
|
event,
|
|
selection
|
|
)
|
|
).resolves.toMatchObject({ id: 'opencode', available: true })
|
|
expect(selectedRuntimes.testStatus).toHaveBeenCalledWith(selection)
|
|
expect(fallbackRuntime.testConnection).not.toHaveBeenCalled()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.runtimeSettingsTest)?.(
|
|
event,
|
|
{ provider: 'opencode', profileId: 'not-a-uuid' }
|
|
)
|
|
).rejects.toThrow()
|
|
expect(selectedRuntimes.testStatus).toHaveBeenCalledOnce()
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers Runtime config actions', () => {
|
|
const temporaryDirectories: string[] = []
|
|
|
|
afterEach(async () => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) =>
|
|
rm(directory, { recursive: true, force: true })
|
|
)
|
|
)
|
|
})
|
|
|
|
it('opens only configured files or the fixed Runtime config directory', async () => {
|
|
const temporaryDirectory = await mkdtemp(
|
|
join(tmpdir(), 'goodbuddy-runtime-config-')
|
|
)
|
|
temporaryDirectories.push(temporaryDirectory)
|
|
const configPath = join(temporaryDirectory, 'config.yaml')
|
|
await writeFile(configPath, 'name: Test', 'utf8')
|
|
const getPublicSettings = vi.fn(async () => ({
|
|
opencodeConfigPath: '',
|
|
continueConfigPath: process.execPath,
|
|
configured: {
|
|
opencodeConfigPath: '',
|
|
continueConfigPath: configPath
|
|
}
|
|
}))
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{ getPublicSettings } as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const canonicalConfigPath = await realpath(configPath)
|
|
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(event, {
|
|
runtime: 'continue',
|
|
action: 'open-file'
|
|
})
|
|
expect(electronMocks.openPath).toHaveBeenCalledWith(
|
|
canonicalConfigPath
|
|
)
|
|
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(event, {
|
|
runtime: 'continue',
|
|
action: 'show-file'
|
|
})
|
|
expect(electronMocks.showItemInFolder).toHaveBeenCalledWith(
|
|
canonicalConfigPath
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(event, {
|
|
runtime: 'continue',
|
|
action: 'open-file',
|
|
path: join(temporaryDirectory, 'attacker-controlled.yaml')
|
|
})
|
|
).rejects.toThrow()
|
|
expect(getPublicSettings).toHaveBeenCalledTimes(2)
|
|
|
|
getPublicSettings.mockResolvedValueOnce({
|
|
opencodeConfigPath: '',
|
|
continueConfigPath: configPath,
|
|
configured: {
|
|
opencodeConfigPath: '',
|
|
continueConfigPath: process.execPath
|
|
}
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(event, {
|
|
runtime: 'continue',
|
|
action: 'open-file'
|
|
})
|
|
).rejects.toThrow('Runtime 配置文件类型不支持直接打开')
|
|
expect(electronMocks.openPath).toHaveBeenCalledTimes(1)
|
|
|
|
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME
|
|
process.env.XDG_CONFIG_HOME = temporaryDirectory
|
|
try {
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(event, {
|
|
runtime: 'opencode',
|
|
action: 'open-directory'
|
|
})
|
|
expect(electronMocks.openPath).toHaveBeenLastCalledWith(
|
|
await realpath(join(temporaryDirectory, 'opencode'))
|
|
)
|
|
} finally {
|
|
if (previousXdgConfigHome === undefined) {
|
|
delete process.env.XDG_CONFIG_HOME
|
|
} else {
|
|
process.env.XDG_CONFIG_HOME = previousXdgConfigHome
|
|
}
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(
|
|
{
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
{
|
|
runtime: 'continue',
|
|
action: 'open-directory'
|
|
}
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers window controls', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('restricts custom chrome controls to the trusted main window', async () => {
|
|
let maximized = false
|
|
const listeners = new Map<string, () => void>()
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => maximized),
|
|
minimize: vi.fn(),
|
|
maximize: vi.fn(() => {
|
|
maximized = true
|
|
}),
|
|
unmaximize: vi.fn(() => {
|
|
maximized = false
|
|
}),
|
|
close: vi.fn(),
|
|
on: vi.fn((name: string, listener: () => void) => {
|
|
listeners.set(name, listener)
|
|
}),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
electronMocks.handlers.get(ipcChannels.windowMinimize)?.(event)
|
|
electronMocks.handlers.get(
|
|
ipcChannels.windowToggleMaximize
|
|
)?.(event)
|
|
listeners.get('maximize')?.()
|
|
electronMocks.handlers.get(ipcChannels.windowClose)?.(event)
|
|
|
|
expect(window.minimize).toHaveBeenCalledOnce()
|
|
expect(window.maximize).toHaveBeenCalledOnce()
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.windowMaximizedChanged,
|
|
true
|
|
)
|
|
expect(window.close).toHaveBeenCalledOnce()
|
|
expect(() =>
|
|
electronMocks.handlers
|
|
.get(ipcChannels.windowIsMaximized)
|
|
?.({
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
})
|
|
).toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
|
|
await dispose()
|
|
expect(window.removeListener).toHaveBeenCalledWith(
|
|
'maximize',
|
|
listeners.get('maximize')
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers workspace files', () => {
|
|
const temporaryDirectories: string[] = []
|
|
|
|
afterEach(async () => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) =>
|
|
rm(directory, { recursive: true, force: true })
|
|
)
|
|
)
|
|
})
|
|
|
|
it('resolves the project root and validates file requests', async () => {
|
|
const rootPath = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-files-'))
|
|
temporaryDirectories.push(rootPath)
|
|
await writeFile(join(rootPath, 'README.md'), '# GoodBuddy\n')
|
|
const projectId = '00000000-0000-4000-8000-000000000101'
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
getProject: vi.fn(() => ({ id: projectId, rootPath }))
|
|
}
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => {}),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
get: vi.fn(async () => {
|
|
throw new Error('settings unavailable')
|
|
})
|
|
} as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
const list = await electronMocks.handlers.get(
|
|
ipcChannels.workspaceDirectoryList
|
|
)?.(event, { projectId, path: '' })
|
|
const preview = await electronMocks.handlers.get(
|
|
ipcChannels.workspaceFileRead
|
|
)?.(event, { projectId, path: 'README.md' })
|
|
|
|
expect(list).toMatchObject({
|
|
entries: [
|
|
{ name: 'README.md', path: 'README.md', type: 'file' }
|
|
]
|
|
})
|
|
expect(preview).toMatchObject({
|
|
path: 'README.md',
|
|
content: '# GoodBuddy\n',
|
|
mimeType: 'text/markdown'
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.(event, {
|
|
projectId,
|
|
path: '../outside.txt'
|
|
})
|
|
).rejects.toThrow('路径必须是工作区内的相对路径')
|
|
expect(assistantDatabase.getProject).toHaveBeenCalledWith(projectId)
|
|
|
|
await dispose()
|
|
})
|
|
|
|
it('fails remote workspace requests before filesystem, Git, or shell access', async () => {
|
|
const projectId = '00000000-0000-4000-8000-000000000102'
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
getProject: vi.fn(() => ({
|
|
id: projectId,
|
|
name: 'Remote',
|
|
description: '',
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000202',
|
|
remoteRootPath: '/srv/project'
|
|
},
|
|
defaultWorkMode: 'ask',
|
|
kind: 'user',
|
|
status: 'active',
|
|
createdAt: '2026-08-01T00:00:00.000Z',
|
|
updatedAt: '2026-08-01T00:00:00.000Z'
|
|
}))
|
|
}
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => {}),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
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('returns the database token summary to a trusted renderer', async () => {
|
|
const summary = {
|
|
totals: {
|
|
callCount: 2,
|
|
input: 120,
|
|
output: 30,
|
|
cacheRead: 10,
|
|
cacheWrite: 5,
|
|
totalTokens: 165
|
|
},
|
|
records: []
|
|
}
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
getTokenUsageSummary: vi.fn(() => summary)
|
|
}
|
|
const webContents = {
|
|
mainFrame: {
|
|
url: 'file:///goodbuddy/index.html'
|
|
},
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
|
|
const handler = electronMocks.handlers.get(
|
|
ipcChannels.tokenUsageSummary
|
|
)
|
|
expect(handler).toBeDefined()
|
|
expect(
|
|
handler?.({
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
})
|
|
).toBe(summary)
|
|
expect(assistantDatabase.getTokenUsageSummary).toHaveBeenCalledOnce()
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers local conversation persistence', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates and forwards incremental saves, branches, and explicit deletions', async () => {
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000301'
|
|
const queuedAttachmentId =
|
|
'00000000-0000-4000-8000-000000000303'
|
|
const queuedItem = {
|
|
id: '00000000-0000-4000-8000-000000000304',
|
|
conversationId,
|
|
source: 'user' as const,
|
|
label: '待删除消息',
|
|
createdAt: '2026-08-20T09:01:00.000Z'
|
|
}
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => [queuedItem]),
|
|
listPendingConversationQueueIds: vi.fn(() => [conversationId]),
|
|
getConversationUserQueuePayloadJson: vi.fn(() =>
|
|
JSON.stringify({
|
|
input: {
|
|
conversationId,
|
|
runtimeSelection: { provider: 'auto' },
|
|
workMode: 'ask',
|
|
includeMemoryContext: true,
|
|
prompt: queuedItem.label,
|
|
attachments: [
|
|
{
|
|
id: queuedAttachmentId,
|
|
name: 'queued.txt',
|
|
size: 6,
|
|
preview: 'queued',
|
|
kind: 'text'
|
|
}
|
|
],
|
|
knowledgeLibraryIds: [],
|
|
knowledgeRetrievalMode: 'auto'
|
|
},
|
|
serializedContexts: '[]'
|
|
})
|
|
),
|
|
saveLocalConversations: vi.fn(),
|
|
branchLocalConversation: vi.fn((input) => ({
|
|
id: '00000000-0000-4000-8000-000000000305',
|
|
branch: {
|
|
sourceConversationId: input.sourceConversationId,
|
|
sourceTitle: '增量会话'
|
|
},
|
|
title: input.title,
|
|
updatedAt: 2,
|
|
messages: []
|
|
})),
|
|
deleteLocalConversation: vi.fn(() => true)
|
|
}
|
|
const contextManager = {
|
|
clear: vi.fn(),
|
|
remove: vi.fn()
|
|
}
|
|
const webContents = {
|
|
mainFrame: {
|
|
url: 'file:///goodbuddy/index.html'
|
|
},
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
contextManager as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const messageId = '00000000-0000-4000-8000-000000000302'
|
|
const batch = [
|
|
{
|
|
header: {
|
|
id: conversationId,
|
|
title: '增量会话',
|
|
updatedAt: 1
|
|
},
|
|
messages: [
|
|
{
|
|
id: messageId,
|
|
role: 'assistant' as const,
|
|
content: '增量内容',
|
|
createdAt: 1,
|
|
state: 'streaming' as const,
|
|
tools: [
|
|
{
|
|
callId: 'remote-tool-call',
|
|
name: '远程工具',
|
|
state: 'completed' as const,
|
|
summary: '完整远程工具结果',
|
|
output: '远程输出'.repeat(10_000)
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
|
|
expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationsSaveLocal
|
|
)?.(event, batch)
|
|
).toBeUndefined()
|
|
expect(
|
|
assistantDatabase.saveLocalConversations
|
|
).toHaveBeenCalledWith(batch)
|
|
const branchInput = {
|
|
sourceConversationId: conversationId,
|
|
title: '增量会话 · 分支'
|
|
}
|
|
expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationsBranchLocal
|
|
)?.(event, branchInput)
|
|
).toMatchObject({
|
|
branch: { sourceConversationId: conversationId },
|
|
title: branchInput.title
|
|
})
|
|
expect(
|
|
assistantDatabase.branchLocalConversation
|
|
).toHaveBeenCalledWith(branchInput)
|
|
expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationsDeleteLocal
|
|
)?.(event, conversationId)
|
|
).toBe(true)
|
|
expect(
|
|
assistantDatabase.deleteLocalConversation
|
|
).toHaveBeenCalledWith(conversationId)
|
|
expect(contextManager.remove).toHaveBeenCalledWith(
|
|
queuedAttachmentId
|
|
)
|
|
|
|
expect(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationsSaveLocal
|
|
)?.(event, [
|
|
{
|
|
...batch[0],
|
|
header: {
|
|
...batch[0]!.header,
|
|
remote: {
|
|
channel: 'weixin',
|
|
accountDisplay: 'remote',
|
|
conversationType: 'direct'
|
|
}
|
|
}
|
|
}
|
|
])
|
|
).toThrow()
|
|
expect(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationsBranchLocal
|
|
)?.(event, {
|
|
sourceConversationId: 'not-a-uuid',
|
|
title: '无效分支'
|
|
})
|
|
).toThrow()
|
|
expect(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationsDeleteLocal
|
|
)?.(event, 'not-a-uuid')
|
|
).toThrow()
|
|
|
|
await dispose()
|
|
})
|
|
|
|
it('waits for the renderer persistence acknowledgement before removing handlers', async () => {
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
}
|
|
const webContents = {
|
|
mainFrame: {
|
|
url: 'file:///goodbuddy/index.html'
|
|
},
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
electronMocks.handlers.get(
|
|
ipcChannels.appRendererPersistenceReady
|
|
)?.(event)
|
|
|
|
const disposal = dispose()
|
|
await vi.waitFor(() =>
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.appRendererPersistenceRequest,
|
|
expect.any(String)
|
|
)
|
|
)
|
|
expect(
|
|
electronMocks.handlers.has(ipcChannels.conversationsSaveLocal)
|
|
).toBe(true)
|
|
const requestId = webContents.send.mock.calls.find(
|
|
([channel]) =>
|
|
channel === ipcChannels.appRendererPersistenceRequest
|
|
)?.[1]
|
|
expect(requestId).toEqual(expect.any(String))
|
|
electronMocks.handlers.get(
|
|
ipcChannels.appRendererPersistenceComplete
|
|
)?.(event, requestId)
|
|
|
|
await disposal
|
|
expect(
|
|
electronMocks.handlers.has(ipcChannels.conversationsSaveLocal)
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers Runtime customization', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates customization, native inventory, and trusted manual compaction', async () => {
|
|
const projectId = '00000000-0000-4000-8000-000000000601'
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000602'
|
|
const requestId = '00000000-0000-4000-8000-000000000603'
|
|
const messageIds = [
|
|
'00000000-0000-4000-8000-000000000604',
|
|
'00000000-0000-4000-8000-000000000605'
|
|
]
|
|
const customization = {
|
|
opencode: { defaultAgent: 'planner' },
|
|
continue: { presets: [] }
|
|
}
|
|
const snapshot = {
|
|
provider: 'opencode' as const,
|
|
available: true,
|
|
inventoryStatus: 'available' as const,
|
|
detail: 'OpenCode native capabilities are ready',
|
|
agents: [
|
|
{
|
|
id: 'planner',
|
|
name: 'Planner',
|
|
mode: 'primary' as const,
|
|
native: true,
|
|
hidden: false
|
|
}
|
|
],
|
|
tools: [
|
|
{
|
|
id: 'edit',
|
|
name: 'edit',
|
|
kind: 'write' as const,
|
|
source: 'runtime' as const,
|
|
ask: 'blocked' as const,
|
|
execute: 'allowed' as const
|
|
}
|
|
],
|
|
toolsSupported: true,
|
|
commands: [],
|
|
lsp: [],
|
|
formatters: [],
|
|
mcpServers: [],
|
|
skills: [],
|
|
rules: [],
|
|
prompts: [],
|
|
resources: [],
|
|
resourcesSupported: true,
|
|
context: {
|
|
strategy: 'native' as const,
|
|
manualCompact: true,
|
|
detail: 'OpenCode manages native context'
|
|
}
|
|
}
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isFocused: vi.fn(() => true),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const settingsStore = {
|
|
getRuntimeCustomization: vi.fn(async () => customization),
|
|
updateRuntimeCustomization: vi.fn(
|
|
async (value: unknown) => value
|
|
),
|
|
getResolvedSettings: vi.fn(async () => ({
|
|
provider: 'opencode',
|
|
modelProfiles: [],
|
|
opencodeBaseUrl: '',
|
|
workspacePath: 'C:\\DefaultWorkspace'
|
|
}))
|
|
}
|
|
const messages = [
|
|
{
|
|
id: messageIds[0]!,
|
|
role: 'user' as const,
|
|
content: 'First turn',
|
|
state: 'complete' as const
|
|
},
|
|
{
|
|
id: messageIds[1]!,
|
|
role: 'assistant' as const,
|
|
content: 'Second turn',
|
|
state: 'complete' as const
|
|
}
|
|
]
|
|
let persistedRuntimeSelection:
|
|
| { provider: 'opencode' }
|
|
| undefined = { provider: 'opencode' }
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
getProject: vi.fn(() => ({
|
|
id: projectId,
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
})),
|
|
getConversation: vi.fn(() => ({
|
|
id: conversationId,
|
|
projectId,
|
|
runtimeSelection: persistedRuntimeSelection,
|
|
title: 'Runtime conversation',
|
|
updatedAt: Date.now(),
|
|
messages
|
|
})),
|
|
createTask: vi.fn(),
|
|
updateTaskStatus: vi.fn(),
|
|
upsertModelUsageCall: vi.fn()
|
|
}
|
|
let releaseFirstCompaction: (() => void) | undefined
|
|
const firstCompactionBlocked = new Promise<void>((resolve) => {
|
|
releaseFirstCompaction = resolve
|
|
})
|
|
const compactionOutcome = {
|
|
result: {
|
|
provider: 'opencode' as const,
|
|
strategy: 'native' as const,
|
|
compacted: true,
|
|
detail: 'OpenCode compacted the conversation'
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getNativeSnapshot: vi.fn(async () => snapshot),
|
|
compactConversation: vi
|
|
.fn()
|
|
.mockImplementationOnce(async () => {
|
|
await firstCompactionBlocked
|
|
return compactionOutcome
|
|
})
|
|
.mockResolvedValue(compactionOutcome)
|
|
}
|
|
const approvalBroker = { clear: vi.fn() }
|
|
const onRuntimeSettingsChanged = vi.fn(async () => undefined)
|
|
const contextManager = { clear: vi.fn() }
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
settingsStore as never,
|
|
{} as never,
|
|
contextManager as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
approvalBroker as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
selectedRuntimes as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationGet
|
|
)?.(event)
|
|
).resolves.toEqual(customization)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationUpdate
|
|
)?.(event, customization)
|
|
).resolves.toEqual(customization)
|
|
expect(
|
|
settingsStore.updateRuntimeCustomization
|
|
).toHaveBeenCalledWith(customization)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
expect(approvalBroker.clear).toHaveBeenCalledOnce()
|
|
const activationError = new Error('customization activation failed')
|
|
onRuntimeSettingsChanged
|
|
.mockRejectedValueOnce(activationError)
|
|
.mockResolvedValueOnce(undefined)
|
|
const failedCustomization = {
|
|
...customization,
|
|
opencode: { defaultAgent: 'candidate-agent' }
|
|
}
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationUpdate
|
|
)?.(event, failedCustomization)
|
|
).rejects.toBe(activationError)
|
|
expect(
|
|
settingsStore.updateRuntimeCustomization
|
|
).toHaveBeenNthCalledWith(2, failedCustomization)
|
|
expect(
|
|
settingsStore.updateRuntimeCustomization
|
|
).toHaveBeenNthCalledWith(3, customization)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(3)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationUpdate
|
|
)?.(event, {
|
|
...customization,
|
|
unknown: true
|
|
})
|
|
).rejects.toThrow()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeNativeSnapshot
|
|
)?.(event, {
|
|
provider: 'opencode',
|
|
projectId
|
|
})
|
|
).resolves.toEqual(snapshot)
|
|
expect(selectedRuntimes.getNativeSnapshot).toHaveBeenCalledWith(
|
|
{ provider: 'opencode' },
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
})
|
|
)
|
|
|
|
const compactInput = {
|
|
requestId,
|
|
conversationId,
|
|
projectId,
|
|
runtimeSelection: { provider: 'opencode' as const },
|
|
history: messages.map(({ role, content }) => ({
|
|
role,
|
|
content
|
|
})),
|
|
historyMessageIds: messageIds
|
|
}
|
|
const firstCompaction = electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, compactInput)
|
|
await vi.waitFor(() =>
|
|
expect(selectedRuntimes.compactConversation).toHaveBeenCalledOnce()
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, compactInput)
|
|
).rejects.toThrow('上下文压缩请求正在执行')
|
|
releaseFirstCompaction?.()
|
|
await expect(firstCompaction).resolves.toEqual({
|
|
provider: 'opencode',
|
|
strategy: 'native',
|
|
compacted: true,
|
|
detail: 'OpenCode compacted the conversation'
|
|
})
|
|
expect(selectedRuntimes.compactConversation).toHaveBeenCalledWith(
|
|
expect.objectContaining(compactInput),
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
}),
|
|
expect.any(AbortSignal)
|
|
)
|
|
expect(assistantDatabase.createTask).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: requestId,
|
|
visible: false
|
|
})
|
|
)
|
|
expect(assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, {
|
|
...compactInput,
|
|
requestId: '00000000-0000-4000-8000-000000000606',
|
|
runtimeSelection: { provider: 'continue' }
|
|
})
|
|
).rejects.toThrow('对话 Runtime 或 Project 已更改')
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, {
|
|
...compactInput,
|
|
requestId: '00000000-0000-4000-8000-000000000607',
|
|
projectId: '00000000-0000-4000-8000-000000000608'
|
|
})
|
|
).rejects.toThrow('对话 Runtime 或 Project 已更改')
|
|
|
|
persistedRuntimeSelection = undefined
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, {
|
|
...compactInput,
|
|
requestId: '00000000-0000-4000-8000-000000000609'
|
|
})
|
|
).resolves.toMatchObject({
|
|
provider: 'opencode',
|
|
compacted: true
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, {
|
|
...compactInput,
|
|
requestId: '00000000-0000-4000-8000-000000000610',
|
|
runtimeSelection: { provider: 'continue' }
|
|
})
|
|
).rejects.toThrow('对话 Runtime 或 Project 已更改')
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, {
|
|
...compactInput,
|
|
requestId: '00000000-0000-4000-8000-000000000611',
|
|
history: [
|
|
compactInput.history[0],
|
|
{ role: 'assistant', content: 'stale content' }
|
|
]
|
|
})
|
|
).rejects.toThrow('对话历史已更改')
|
|
expect(selectedRuntimes.compactConversation).toHaveBeenCalledTimes(2)
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers agent terminal state', () => {
|
|
type RemoteTaskEventMockInput = {
|
|
taskId: string
|
|
bindingId: string
|
|
operationId: string
|
|
semanticSequence: string
|
|
eventIndex: number
|
|
kind: string
|
|
payload: unknown
|
|
}
|
|
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
function createHarness(
|
|
runtime: Record<string, unknown>,
|
|
onBeforeClearLocalData?: () => Promise<void>,
|
|
toolApproval: 'always' | 'policy' = 'always',
|
|
subagentService?: Record<string, unknown>,
|
|
smartRoutingEnabled = false,
|
|
selectedRuntimes?: Record<string, unknown>,
|
|
knowledgeServiceOverride?: Record<string, unknown>,
|
|
knowledgeGateway?: Record<string, unknown>,
|
|
magicNotesEnabled = false,
|
|
goodbuddyConfigService?: Record<string, unknown>,
|
|
capabilityServiceOverride?: Record<string, unknown>
|
|
) {
|
|
const assistantDatabase = {
|
|
createTask: vi.fn(),
|
|
appendTaskEvent: vi.fn(),
|
|
appendRemoteTaskEventOnce: vi.fn<
|
|
(input: RemoteTaskEventMockInput) => boolean
|
|
>(() => true),
|
|
appendRemoteConversationTaskEventOnce: vi.fn(() => true),
|
|
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'
|
|
),
|
|
failRecoverableRemoteTask: 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',
|
|
executionSpace: {
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
},
|
|
defaultWorkMode: 'ask',
|
|
runtimeSelection: {
|
|
provider: 'model',
|
|
profileId: '00000000-0000-4000-8000-000000000001'
|
|
},
|
|
kind: 'channel',
|
|
channel: 'wecom',
|
|
status: 'active',
|
|
createdAt: '2026-08-04T00:00:00.000Z',
|
|
updatedAt: '2026-08-04T00:00:00.000Z'
|
|
}
|
|
]),
|
|
getOrCreateRemoteConversation: vi.fn(() => ({
|
|
id: '00000000-0000-4000-8000-000000000402',
|
|
projectId: '00000000-0000-4000-8000-000000000401',
|
|
title: '企业微信 · ****er-1',
|
|
updatedAt: Date.now(),
|
|
messages: []
|
|
})),
|
|
appendConversationMessage: vi.fn(),
|
|
appendRemoteConversationMessage: vi.fn(),
|
|
listConversationQueueItems: vi.fn<
|
|
() => ConversationQueueItem[]
|
|
>(() => []),
|
|
listPendingScheduleQueueConversationIds: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
getConversationQueueItem: vi.fn(),
|
|
getConversationUserQueuePayloadJson: vi.fn(),
|
|
isConversationUserQueueItemDispatching: vi.fn(() => true),
|
|
enqueueConversationUserInput: vi.fn(),
|
|
claimConversationQueueItem: vi.fn(),
|
|
completeConversationUserQueueItem: vi.fn(),
|
|
releaseConversationUserQueueItem: vi.fn(),
|
|
cancelConversationQueueItem: vi.fn(),
|
|
queueDueSchedules: vi.fn(() => []),
|
|
queueScheduleNow: vi.fn(),
|
|
completeScheduleRun: vi.fn(),
|
|
branchLocalConversation: vi.fn(() => ({
|
|
id: '00000000-0000-4000-8000-000000000403',
|
|
branch: {
|
|
sourceConversationId:
|
|
'00000000-0000-4000-8000-000000000402',
|
|
sourceTitle: '来源会话'
|
|
},
|
|
title: '来源会话 · 分支',
|
|
updatedAt: Date.now(),
|
|
messages: []
|
|
})),
|
|
listSchedules: vi.fn(() => []),
|
|
createSchedule: vi.fn(),
|
|
setScheduleEnabled: vi.fn(),
|
|
removeSchedule: vi.fn(),
|
|
upsertModelUsageCall: vi.fn(),
|
|
clearAssistantData: vi.fn(),
|
|
listExperts: vi.fn<() => Array<Record<string, unknown>>>(() => []),
|
|
getExpert: vi.fn(),
|
|
getConversation: vi.fn<
|
|
(conversationId: string) => Record<string, unknown>
|
|
>((conversationId) => ({
|
|
id: conversationId,
|
|
runtimeSelection: { provider: 'opencode' },
|
|
messages: []
|
|
})),
|
|
getProject: vi.fn<
|
|
(projectId: string) => Record<string, unknown>
|
|
>((projectId) => ({
|
|
id: projectId,
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
}))
|
|
}
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isFocused: vi.fn(() => true),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const contextManager = {
|
|
enrichRequest: vi.fn((request) => request),
|
|
ingestRemoteAttachment: vi.fn(async (attachment: {
|
|
name: string
|
|
size: number
|
|
kind: 'image' | 'file'
|
|
}) => ({
|
|
id: '00000000-0000-4000-8000-000000000498',
|
|
name: attachment.name,
|
|
size: attachment.size,
|
|
preview: '远程附件',
|
|
kind: attachment.kind === 'image' ? 'image' : 'text'
|
|
})),
|
|
remove: vi.fn(),
|
|
serializeForQueue: vi.fn(() => '[]'),
|
|
restoreFromQueue: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const approvalBroker = {
|
|
request: vi.fn(),
|
|
respond: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const getResolvedSettings = vi.fn(
|
|
async (): Promise<Record<string, unknown>> => ({
|
|
toolApproval,
|
|
subagentSmartRoutingEnabled: smartRoutingEnabled
|
|
})
|
|
)
|
|
const getPolicySettings = vi.fn(
|
|
async (): Promise<Record<string, unknown>> => ({
|
|
toolApproval,
|
|
subagentSmartRoutingEnabled: smartRoutingEnabled
|
|
})
|
|
)
|
|
const getApplicationSettings = vi.fn(
|
|
async (): Promise<Record<string, unknown>> => ({
|
|
magicNotesEnabled,
|
|
remoteProjectsEnabled: true
|
|
})
|
|
)
|
|
const onRuntimeSettingsChanged = vi.fn(async () => {})
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
runtime as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{
|
|
getPolicySettings,
|
|
getResolvedSettings,
|
|
getRuntimeCustomization: vi.fn(async () => ({
|
|
opencode: {},
|
|
continue: { presets: [] }
|
|
})),
|
|
updateRuntimeCustomization: vi.fn(
|
|
async (settings: unknown) => settings
|
|
)
|
|
} as never,
|
|
(capabilityServiceOverride ?? {}) as never,
|
|
contextManager as never,
|
|
(knowledgeServiceOverride ?? {
|
|
database: { listKnowledgeBases: vi.fn(() => []) }
|
|
}) as never,
|
|
assistantDatabase as never,
|
|
approvalBroker as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged,
|
|
onBeforeClearLocalData,
|
|
undefined,
|
|
subagentService as never,
|
|
undefined,
|
|
{
|
|
get: getApplicationSettings
|
|
} as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
selectedRuntimes as never,
|
|
undefined,
|
|
knowledgeGateway as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
goodbuddyConfigService as never
|
|
)
|
|
return {
|
|
approvalBroker,
|
|
assistantDatabase,
|
|
contextManager,
|
|
dispose,
|
|
getApplicationSettings,
|
|
getPolicySettings,
|
|
getResolvedSettings,
|
|
onRuntimeSettingsChanged,
|
|
clearHandler: electronMocks.handlers.get(
|
|
ipcChannels.appClearLocalData
|
|
),
|
|
handler: electronMocks.handlers.get(ipcChannels.agentRun),
|
|
statusHandler: electronMocks.handlers.get(ipcChannels.agentStatus),
|
|
cancelHandler: electronMocks.handlers.get(ipcChannels.agentCancel),
|
|
recoveryGetHandler: electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectRecoveryGet
|
|
),
|
|
recoveryRetryHandler: electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectRecoveryRetry
|
|
),
|
|
branchHandler: electronMocks.handlers.get(
|
|
ipcChannels.conversationsBranchLocal
|
|
),
|
|
knowledgeSearchHandler: electronMocks.handlers.get(
|
|
ipcChannels.knowledgeSearch
|
|
),
|
|
knowledgeRebuildHandler: electronMocks.handlers.get(
|
|
ipcChannels.knowledgeRebuildLibrary
|
|
),
|
|
knowledgeCancelRebuildHandler: electronMocks.handlers.get(
|
|
ipcChannels.knowledgeCancelRebuild
|
|
),
|
|
webContents
|
|
}
|
|
}
|
|
|
|
const trustedEvent = (webContents: {
|
|
mainFrame: { url: string }
|
|
}) => ({
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
})
|
|
|
|
function createManagedSshHarness(
|
|
selectedRuntime: Record<string, unknown>
|
|
) {
|
|
const projectId = '00000000-0000-4000-8000-000000000761'
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => selectedRuntime)
|
|
}
|
|
const harness = createHarness(
|
|
{
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
run: vi.fn()
|
|
},
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
id: projectId,
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000762',
|
|
remoteRootPath: '/srv/project'
|
|
}
|
|
})
|
|
return { harness, projectId, selectedRuntimes }
|
|
}
|
|
|
|
const managedSshRequest = (
|
|
projectId: string,
|
|
requestId: string,
|
|
conversationId: string
|
|
) => ({
|
|
requestId,
|
|
conversationId,
|
|
projectId,
|
|
runtimeSelection: { provider: 'opencode' as const },
|
|
prompt: 'continue on Agent',
|
|
workMode: 'execute' as const,
|
|
knowledgeLibraryIds: []
|
|
})
|
|
|
|
it('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: '2026-08-21T09:00:00.000Z'
|
|
}
|
|
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.schedulesCreate
|
|
)?.(trustedEvent(harness.webContents), createInput)
|
|
|
|
expect(
|
|
harness.assistantDatabase.createSchedule
|
|
).toHaveBeenCalledWith({
|
|
...createInput,
|
|
workMode: 'execute'
|
|
})
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('reuses a scheduled Task and writes text results to its Conversation', async () => {
|
|
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 },
|
|
_signal: AbortSignal,
|
|
authorize: (
|
|
input: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}
|
|
) => Promise<string>
|
|
) {
|
|
expect(request.requestId).toBe(runId)
|
|
await authorize({
|
|
scopeKey: 'workspace.write',
|
|
title: '写入工作区',
|
|
description: '更新状态文件'
|
|
})
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: '每日状态正常'
|
|
} as const
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
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.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)
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.completeScheduleRun
|
|
).toHaveBeenCalledWith(runId, 'completed')
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(taskId, 'running')
|
|
expect(harness.approvalBroker.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
requestId: taskId,
|
|
conversationId
|
|
}),
|
|
expect.any(AbortSignal),
|
|
expect.any(Function)
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.appendConversationMessage
|
|
).toHaveBeenCalledWith({
|
|
conversationId,
|
|
role: 'assistant',
|
|
content: '每日状态正常',
|
|
status: '定时任务',
|
|
task: {
|
|
id: taskId,
|
|
title: '每日状态'
|
|
}
|
|
})
|
|
expect(
|
|
harness.assistantDatabase.createTextArtifact
|
|
).not.toHaveBeenCalled()
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationsChanged
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('fails scheduled runs promptly when a Runtime asks an interactive question', 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
|
|
await new Promise(() => undefined)
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
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.listConversationQueueItems
|
|
.mockReturnValueOnce([queueItem])
|
|
.mockReturnValue([])
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValue({
|
|
source: 'schedule',
|
|
item: queueItem,
|
|
schedule,
|
|
runId
|
|
})
|
|
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.schedulesRunNow
|
|
)?.(trustedEvent(harness.webContents), scheduleId)
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.completeScheduleRun
|
|
).toHaveBeenCalledWith(runId, 'failed')
|
|
)
|
|
expect(respondToQuestion).toHaveBeenCalledWith(
|
|
'opencode-background-question'
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.appendConversationMessage
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
conversationId,
|
|
state: 'error',
|
|
status: '定时任务失败',
|
|
content:
|
|
'后台任务无法回答 Runtime 交互提问。请改为在 GoodBuddy 对话中运行,或调整提示词和工具配置以避免交互提问。'
|
|
})
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('serializes Agent runs that target the same Conversation', async () => {
|
|
let finishRun: (() => void) | undefined
|
|
const runtimeStarted = vi.fn()
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) {
|
|
runtimeStarted(request.requestId)
|
|
await new Promise<void>((resolve) => {
|
|
finishRun = resolve
|
|
})
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000721'
|
|
const firstRun = harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
{
|
|
requestId: '00000000-0000-4000-8000-000000000722',
|
|
conversationId,
|
|
prompt: '第一条',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
}
|
|
)
|
|
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
|
|
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000723',
|
|
conversationId,
|
|
prompt: '第二条',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
).rejects.toThrow('当前对话已有执行中的请求')
|
|
|
|
finishRun?.()
|
|
await firstRun
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(
|
|
'00000000-0000-4000-8000-000000000722',
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000724',
|
|
conversationId,
|
|
prompt: '第三条',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
).resolves.toBeUndefined()
|
|
await vi.waitFor(() =>
|
|
expect(runtimeStarted).toHaveBeenCalledTimes(2)
|
|
)
|
|
finishRun?.()
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(
|
|
'00000000-0000-4000-8000-000000000724',
|
|
'completed'
|
|
)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('keeps a replacement lease owned after the bulk-aborted lease releases', async () => {
|
|
const controls = new Map<
|
|
string,
|
|
{
|
|
finish: () => void
|
|
signal: AbortSignal
|
|
}
|
|
>()
|
|
const runtimeStarted = vi.fn()
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(
|
|
request: { conversationId: string; requestId: string },
|
|
signal: AbortSignal
|
|
) {
|
|
runtimeStarted(request.conversationId)
|
|
await new Promise<void>((resolve) => {
|
|
controls.set(request.conversationId, {
|
|
finish: resolve,
|
|
signal
|
|
})
|
|
})
|
|
signal.throwIfAborted()
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const goodbuddyConfigService = {
|
|
takePendingReload: vi.fn(() => 'none'),
|
|
revokeRequest: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'lease-race-capability'),
|
|
getAvailableToolNames: vi.fn(() => []),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
false,
|
|
goodbuddyConfigService
|
|
)
|
|
const requestId = '00000000-0000-4000-8000-000000000741'
|
|
const oldConversationId =
|
|
'00000000-0000-4000-8000-000000000742'
|
|
const newConversationId =
|
|
'00000000-0000-4000-8000-000000000743'
|
|
const event = trustedEvent(harness.webContents)
|
|
|
|
await harness.handler?.(event, {
|
|
requestId,
|
|
conversationId: oldConversationId,
|
|
prompt: '旧请求',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(controls.has(oldConversationId)).toBe(true)
|
|
)
|
|
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationUpdate
|
|
)?.(event, {
|
|
opencode: {},
|
|
continue: { presets: [] }
|
|
})
|
|
expect(controls.get(oldConversationId)?.signal.aborted).toBe(true)
|
|
|
|
await harness.handler?.(event, {
|
|
requestId,
|
|
conversationId: newConversationId,
|
|
prompt: '替换请求',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(controls.has(newConversationId)).toBe(true)
|
|
)
|
|
|
|
controls.get(oldConversationId)?.finish()
|
|
await vi.waitFor(() =>
|
|
expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce()
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
|
|
|
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('detaches an accepted managed SSH run without aborting it or waiting for completion', async () => {
|
|
let finishRun = (): void => undefined
|
|
let remoteSignal: AbortSignal | undefined
|
|
let statusConsumed = false
|
|
const remoteRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(
|
|
request: { requestId: string },
|
|
signal: AbortSignal
|
|
) {
|
|
remoteSignal = signal
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'status',
|
|
message: '远端 ACP Runtime 正在处理请求'
|
|
} as const
|
|
statusConsumed = true
|
|
await new Promise<void>((resolve) => {
|
|
finishRun = resolve
|
|
})
|
|
signal.throwIfAborted()
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => remoteRuntime)
|
|
}
|
|
const harness = createHarness(
|
|
remoteRuntime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
const projectId = '00000000-0000-4000-8000-000000000751'
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
id: projectId,
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000752',
|
|
remoteRootPath: '/srv/project'
|
|
}
|
|
})
|
|
const requestId = '00000000-0000-4000-8000-000000000753'
|
|
const run = harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
{
|
|
requestId,
|
|
conversationId: 'managed-ssh-conversation',
|
|
projectId,
|
|
runtimeSelection: { provider: 'opencode' },
|
|
prompt: 'continue on Agent',
|
|
workMode: 'execute',
|
|
knowledgeLibraryIds: []
|
|
}
|
|
)
|
|
await run
|
|
await vi.waitFor(() => expect(statusConsumed).toBe(true))
|
|
|
|
const disposal = harness.dispose()
|
|
await expect(disposal).resolves.toBeUndefined()
|
|
expect(remoteSignal?.aborted).toBe(false)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
|
|
|
finishRun()
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
})
|
|
|
|
it('durably commits remote text before resume, deduplicates it, and preserves its semantic payload', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000763'
|
|
const provenance = {
|
|
source: 'remote-semantic-transcript' as const,
|
|
bindingId: 'binding-semantic-text',
|
|
operationId: 'operation-semantic-text',
|
|
semanticSequence: '1',
|
|
eventIndex: 0
|
|
}
|
|
const taggedDelta = '<think>remote reasoning</think>remote answer'
|
|
const resumed = vi.fn()
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
const remoteText = {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: taggedDelta,
|
|
remoteProvenance: provenance
|
|
} as const
|
|
yield remoteText
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteTaskEventOnce
|
|
).toHaveBeenCalledWith({
|
|
taskId: request.requestId,
|
|
bindingId: provenance.bindingId,
|
|
operationId: provenance.operationId,
|
|
semanticSequence: provenance.semanticSequence,
|
|
eventIndex: provenance.eventIndex,
|
|
kind: 'text',
|
|
payload: {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: taggedDelta
|
|
}
|
|
})
|
|
resumed('first')
|
|
yield remoteText
|
|
resumed('duplicate')
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done',
|
|
remoteProvenance: {
|
|
...provenance,
|
|
semanticSequence: '2'
|
|
}
|
|
} as const
|
|
}
|
|
}
|
|
const fixture = createManagedSshHarness(selectedRuntime)
|
|
const { harness } = fixture
|
|
harness.assistantDatabase.appendRemoteTaskEventOnce
|
|
.mockReturnValueOnce(true)
|
|
.mockReturnValueOnce(false)
|
|
.mockReturnValueOnce(true)
|
|
|
|
await harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
managedSshRequest(
|
|
fixture.projectId,
|
|
requestId,
|
|
'managed-ssh-semantic-text'
|
|
)
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness?.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
|
|
expect(resumed.mock.calls).toEqual([['first'], ['duplicate']])
|
|
const persistedTextCalls =
|
|
harness.assistantDatabase.appendRemoteTaskEventOnce.mock.calls.filter(
|
|
([input]) => input.kind === 'text'
|
|
)
|
|
expect(persistedTextCalls).toHaveLength(2)
|
|
const publicEvents = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, event]) => event)
|
|
.filter((event) => event.requestId === requestId)
|
|
expect(publicEvents).toEqual([
|
|
{
|
|
requestId,
|
|
type: 'text',
|
|
delta: taggedDelta
|
|
},
|
|
{
|
|
requestId,
|
|
type: 'done'
|
|
}
|
|
])
|
|
expect(
|
|
publicEvents.some((event) => event.type === 'reasoning')
|
|
).toBe(false)
|
|
expect(
|
|
publicEvents.some((event) => 'remoteProvenance' in event)
|
|
).toBe(false)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('persists a remote semantic checkpoint without publishing it', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000764'
|
|
const provenance = {
|
|
source: 'remote-semantic-transcript' as const,
|
|
bindingId: 'binding-semantic-checkpoint',
|
|
operationId: 'operation-semantic-checkpoint',
|
|
semanticSequence: '1',
|
|
eventIndex: 0
|
|
}
|
|
const resumedAfterCheckpoint = vi.fn()
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'remote-semantic-checkpoint',
|
|
remoteProvenance: provenance
|
|
} as const
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteTaskEventOnce
|
|
).toHaveBeenCalledWith({
|
|
taskId: request.requestId,
|
|
bindingId: provenance.bindingId,
|
|
operationId: provenance.operationId,
|
|
semanticSequence: provenance.semanticSequence,
|
|
eventIndex: provenance.eventIndex,
|
|
kind: 'remote-semantic-checkpoint',
|
|
payload: {
|
|
requestId: request.requestId,
|
|
type: 'remote-semantic-checkpoint'
|
|
}
|
|
})
|
|
resumedAfterCheckpoint()
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done',
|
|
remoteProvenance: {
|
|
...provenance,
|
|
semanticSequence: '2'
|
|
}
|
|
} as const
|
|
}
|
|
}
|
|
const fixture = createManagedSshHarness(selectedRuntime)
|
|
const { harness } = fixture
|
|
|
|
await harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
managedSshRequest(
|
|
fixture.projectId,
|
|
requestId,
|
|
'managed-ssh-semantic-checkpoint'
|
|
)
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
|
|
expect(resumedAfterCheckpoint).toHaveBeenCalledOnce()
|
|
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({
|
|
requestId,
|
|
type: 'remote-semantic-checkpoint'
|
|
})
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('persists remote done before resuming the generator once for Agent acknowledgement', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000765'
|
|
const lifecycle: string[] = []
|
|
const provenance = {
|
|
source: 'remote-semantic-transcript' as const,
|
|
bindingId: 'binding-semantic-done',
|
|
operationId: 'operation-semantic-done',
|
|
semanticSequence: '1',
|
|
eventIndex: 0
|
|
}
|
|
const resumedAfterDone = vi.fn(() => {
|
|
lifecycle.push('resume-done')
|
|
})
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done',
|
|
remoteProvenance: provenance
|
|
} as const
|
|
resumedAfterDone()
|
|
}
|
|
}
|
|
const { harness, projectId } =
|
|
createManagedSshHarness(selectedRuntime)
|
|
harness.assistantDatabase.appendRemoteTaskEventOnce.mockImplementation(
|
|
(input) => {
|
|
lifecycle.push(`persist-${input.kind}`)
|
|
return true
|
|
}
|
|
)
|
|
|
|
await harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
managedSshRequest(
|
|
projectId,
|
|
requestId,
|
|
'managed-ssh-semantic-done'
|
|
)
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
|
|
expect(lifecycle).toEqual(['persist-done', 'resume-done'])
|
|
expect(resumedAfterDone).toHaveBeenCalledOnce()
|
|
expect(
|
|
harness.assistantDatabase.appendTaskEvent
|
|
).not.toHaveBeenCalledWith(
|
|
requestId,
|
|
'done',
|
|
expect.anything()
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('persists and acknowledges a remote terminal error before failing without duplicate delivery', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000766'
|
|
const errorMessage = 'remote Agent failed'
|
|
const lifecycle: string[] = []
|
|
const provenance = {
|
|
source: 'remote-semantic-transcript' as const,
|
|
bindingId: 'binding-semantic-error',
|
|
operationId: 'operation-semantic-error',
|
|
semanticSequence: '1',
|
|
eventIndex: 0
|
|
}
|
|
const resumedAfterError = vi.fn(() => {
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
expect.any(String)
|
|
)
|
|
lifecycle.push('resume-error')
|
|
})
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'error',
|
|
status: 'failed',
|
|
message: errorMessage,
|
|
remoteProvenance: provenance
|
|
} as const
|
|
resumedAfterError()
|
|
}
|
|
}
|
|
const fixture = createManagedSshHarness(selectedRuntime)
|
|
const { harness } = fixture
|
|
harness.assistantDatabase.appendRemoteTaskEventOnce.mockImplementation(
|
|
(input) => {
|
|
lifecycle.push(`persist-${input.kind}`)
|
|
return true
|
|
}
|
|
)
|
|
|
|
await harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
managedSshRequest(
|
|
fixture.projectId,
|
|
requestId,
|
|
'managed-ssh-semantic-error'
|
|
)
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
expect.any(String)
|
|
)
|
|
)
|
|
|
|
expect(lifecycle).toEqual(['persist-error', 'resume-error'])
|
|
expect(resumedAfterError).toHaveBeenCalledOnce()
|
|
expect(
|
|
harness.assistantDatabase.appendTaskEvent
|
|
).not.toHaveBeenCalledWith(
|
|
requestId,
|
|
'error',
|
|
expect.anything()
|
|
)
|
|
const publicErrors = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, event]) => event)
|
|
.filter(
|
|
(event) =>
|
|
event.requestId === requestId && event.type === 'error'
|
|
)
|
|
expect(publicErrors).toEqual([
|
|
{
|
|
requestId,
|
|
type: 'error',
|
|
status: 'failed',
|
|
message: errorMessage
|
|
}
|
|
])
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('reattaches a recoverable task from its committed cursor and completes project synchronization', async () => {
|
|
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',
|
|
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
|
|
yield {
|
|
requestId: taskId,
|
|
type: 'done',
|
|
sessionId: 'session-recovery',
|
|
remoteProvenance: {
|
|
source: 'remote-semantic-transcript',
|
|
bindingId: 'binding-recovery',
|
|
operationId: taskId,
|
|
semanticSequence: '4',
|
|
eventIndex: 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: 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.recoveryGetHandler?.(
|
|
trustedEvent(harness.webContents)
|
|
)
|
|
|
|
await vi.waitFor(() => expect(consumed).toEqual(['3', '4']))
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteConversationTaskEventOnce
|
|
).toHaveBeenCalledTimes(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(
|
|
3,
|
|
expect.objectContaining({
|
|
taskId,
|
|
semanticSequence: '4',
|
|
event: {
|
|
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('aborts and drains a local run during Desktop shutdown', async () => {
|
|
let localSignal: AbortSignal | undefined
|
|
let finishLocalCleanup = (): void => undefined
|
|
let markLocalCleanupStarted = (): void => undefined
|
|
const localCleanupStarted = new Promise<void>((resolve) => {
|
|
markLocalCleanupStarted = resolve
|
|
})
|
|
const localRuntime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(
|
|
request: { requestId: string },
|
|
signal: AbortSignal
|
|
) {
|
|
localSignal = signal
|
|
await new Promise<void>((resolve) => {
|
|
signal.addEventListener('abort', () => resolve(), {
|
|
once: true
|
|
})
|
|
})
|
|
await new Promise<void>((resolve) => {
|
|
finishLocalCleanup = resolve
|
|
markLocalCleanupStarted()
|
|
})
|
|
signal.throwIfAborted()
|
|
yield { requestId: request.requestId, type: 'done' } as const
|
|
}
|
|
}
|
|
const harness = createHarness(localRuntime)
|
|
const requestId = '00000000-0000-4000-8000-000000000754'
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'local-shutdown-conversation',
|
|
prompt: 'local work',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() => expect(localSignal).toBeDefined())
|
|
|
|
const event = trustedEvent(harness.webContents)
|
|
electronMocks.handlers.get(
|
|
ipcChannels.appRendererPersistenceReady
|
|
)?.(event)
|
|
const disposal = harness.dispose()
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.appRendererPersistenceRequest,
|
|
expect.any(String)
|
|
)
|
|
)
|
|
|
|
expect(localSignal?.aborted).toBe(true)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
|
const persistenceRequestId =
|
|
harness.webContents.send.mock.calls.find(
|
|
([channel]) =>
|
|
channel === ipcChannels.appRendererPersistenceRequest
|
|
)?.[1]
|
|
electronMocks.handlers.get(
|
|
ipcChannels.appRendererPersistenceComplete
|
|
)?.(event, persistenceRequestId)
|
|
await localCleanupStarted
|
|
finishLocalCleanup()
|
|
await disposal
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
|
})
|
|
|
|
it('keeps an accepted managed SSH run recoverable after user Stop', async () => {
|
|
let remoteSignal: AbortSignal | undefined
|
|
let statusConsumed = false
|
|
const remoteRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(
|
|
request: { requestId: string },
|
|
signal: AbortSignal
|
|
) {
|
|
remoteSignal = signal
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'status',
|
|
message: '远端 ACP Runtime 正在处理请求'
|
|
} as const
|
|
statusConsumed = true
|
|
await new Promise<void>((resolve) => {
|
|
signal.addEventListener('abort', () => resolve(), {
|
|
once: true
|
|
})
|
|
})
|
|
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)
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'interrupted', '请求已取消')
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('keeps a managed SSH start ambiguity recoverable before its first event', async () => {
|
|
const remoteRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(): AsyncGenerator<never, void, void> {
|
|
await Promise.reject(
|
|
new Error('prompt start response was lost')
|
|
)
|
|
yield undefined as never
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => remoteRuntime)
|
|
}
|
|
const harness = createHarness(
|
|
remoteRuntime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
const projectId = '00000000-0000-4000-8000-000000000760'
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
id: projectId,
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000761',
|
|
remoteRootPath: '/srv/project'
|
|
}
|
|
})
|
|
const requestId = '00000000-0000-4000-8000-000000000762'
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'managed-ssh-start-ambiguity',
|
|
projectId,
|
|
runtimeSelection: { provider: 'opencode' },
|
|
prompt: 'start once',
|
|
workMode: 'execute',
|
|
knowledgeLibraryIds: [],
|
|
currentUserMessageId:
|
|
'00000000-0000-4000-8000-000000000763',
|
|
currentAssistantMessageId:
|
|
'00000000-0000-4000-8000-000000000764'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(
|
|
requestId,
|
|
'interrupted',
|
|
'prompt start response was lost'
|
|
)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('reserves a Conversation while its Runtime is resolving', async () => {
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
let resolveRuntime:
|
|
| ((value: typeof runtime) => void)
|
|
| undefined
|
|
const runtimeResolution = new Promise<typeof runtime>((resolve) => {
|
|
resolveRuntime = resolve
|
|
})
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(() => runtimeResolution)
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000724'
|
|
const firstRun = harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
{
|
|
requestId: '00000000-0000-4000-8000-000000000728',
|
|
conversationId,
|
|
runtimeSelection: { provider: 'auto' },
|
|
prompt: '等待 Runtime',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
}
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
|
|
)
|
|
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000729',
|
|
conversationId,
|
|
runtimeSelection: { provider: 'auto' },
|
|
prompt: '不能并发',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
).rejects.toThrow('当前对话已有执行中的请求')
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
|
|
expect(() =>
|
|
harness.branchHandler?.(trustedEvent(harness.webContents), {
|
|
sourceConversationId: conversationId,
|
|
title: '等待 Runtime · 分支'
|
|
})
|
|
).toThrow('当前会话仍有正在执行的请求')
|
|
expect(
|
|
harness.assistantDatabase.branchLocalConversation
|
|
).not.toHaveBeenCalled()
|
|
|
|
resolveRuntime?.(runtime)
|
|
await firstRun
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('interrupts the active response and promotes the selected queue item', async () => {
|
|
const runtimeStarted = vi.fn()
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(
|
|
request: { requestId: string },
|
|
signal: AbortSignal
|
|
) {
|
|
runtimeStarted(request.requestId)
|
|
await new Promise<void>((resolve) => {
|
|
signal.addEventListener('abort', () => resolve(), {
|
|
once: true
|
|
})
|
|
})
|
|
if (signal.aborted) {
|
|
throw signal.reason
|
|
}
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000725'
|
|
const item = {
|
|
id: '00000000-0000-4000-8000-000000000726',
|
|
conversationId,
|
|
source: 'user' as const,
|
|
label: '优先执行',
|
|
createdAt: '2026-08-20T09:01:00.000Z'
|
|
}
|
|
const input = {
|
|
conversationId,
|
|
runtimeSelection: { provider: 'auto' as const },
|
|
workMode: 'ask' as const,
|
|
includeMemoryContext: true,
|
|
prompt: item.label,
|
|
attachments: [],
|
|
knowledgeLibraryIds: [],
|
|
knowledgeRetrievalMode: 'auto' as const
|
|
}
|
|
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
|
|
item
|
|
)
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({
|
|
source: 'user',
|
|
item,
|
|
payloadJson: JSON.stringify(input)
|
|
})
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueReady
|
|
)?.(trustedEvent(harness.webContents), conversationId)
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000727',
|
|
conversationId,
|
|
prompt: '当前回复',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
|
|
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueInterruptAndRun
|
|
)?.(trustedEvent(harness.webContents), item.id)
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch,
|
|
{ item, input }
|
|
)
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.claimConversationQueueItem
|
|
).toHaveBeenCalledWith(conversationId, item.id)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('dispatches and accepts a queued user message through the renderer', async () => {
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000731'
|
|
const itemId = '00000000-0000-4000-8000-000000000732'
|
|
const attachmentId =
|
|
'00000000-0000-4000-8000-000000000734'
|
|
const input = {
|
|
conversationId,
|
|
runtimeSelection: { provider: 'auto' as const },
|
|
workMode: 'ask' as const,
|
|
includeMemoryContext: true,
|
|
prompt: '排队发送',
|
|
attachments: [
|
|
{
|
|
id: attachmentId,
|
|
name: 'queued.txt',
|
|
size: 6,
|
|
preview: 'queued',
|
|
kind: 'text' as const
|
|
}
|
|
],
|
|
knowledgeLibraryIds: [],
|
|
knowledgeRetrievalMode: 'auto' as const
|
|
}
|
|
const item = {
|
|
id: itemId,
|
|
conversationId,
|
|
source: 'user' as const,
|
|
label: input.prompt,
|
|
createdAt: '2026-08-19T00:01:00.000Z'
|
|
}
|
|
harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue(
|
|
item
|
|
)
|
|
harness.assistantDatabase.listConversationQueueItems
|
|
.mockReturnValueOnce([item])
|
|
.mockReturnValue([])
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({
|
|
source: 'user',
|
|
item,
|
|
payloadJson: JSON.stringify({
|
|
input,
|
|
serializedContexts: '[]'
|
|
})
|
|
})
|
|
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
|
|
item
|
|
)
|
|
|
|
const enqueueHandler = electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueEnqueueUser
|
|
)
|
|
expect(
|
|
enqueueHandler?.(trustedEvent(harness.webContents), input)
|
|
).toEqual(item)
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch,
|
|
{ item, input }
|
|
)
|
|
)
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000733',
|
|
conversationId,
|
|
queueItemId: itemId,
|
|
runtimeSelection: input.runtimeSelection,
|
|
prompt: input.prompt,
|
|
workMode: input.workMode,
|
|
knowledgeLibraryIds: []
|
|
})
|
|
|
|
expect(
|
|
harness.assistantDatabase.completeConversationUserQueueItem
|
|
).toHaveBeenCalledWith(itemId)
|
|
expect(harness.contextManager.serializeForQueue).toHaveBeenCalledWith([
|
|
attachmentId
|
|
])
|
|
expect(harness.contextManager.remove).toHaveBeenCalledWith(
|
|
attachmentId
|
|
)
|
|
expect(harness.contextManager.restoreFromQueue).toHaveBeenCalledWith(
|
|
'[]'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('releases the queue reservation when a dispatched run cannot start', async () => {
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000745'
|
|
const itemId = '00000000-0000-4000-8000-000000000746'
|
|
const input = {
|
|
conversationId,
|
|
runtimeSelection: { provider: 'auto' as const },
|
|
workMode: 'ask' as const,
|
|
includeMemoryContext: true,
|
|
prompt: '需要重新排队',
|
|
attachments: [],
|
|
knowledgeLibraryIds: [],
|
|
knowledgeRetrievalMode: 'auto' as const
|
|
}
|
|
const item = {
|
|
id: itemId,
|
|
conversationId,
|
|
source: 'user' as const,
|
|
label: input.prompt,
|
|
createdAt: '2026-08-21T00:02:00.000Z'
|
|
}
|
|
harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue(
|
|
item
|
|
)
|
|
harness.assistantDatabase.listConversationQueueItems
|
|
.mockReturnValueOnce([item])
|
|
.mockReturnValue([])
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce(
|
|
{
|
|
source: 'user',
|
|
item,
|
|
payloadJson: JSON.stringify({
|
|
input,
|
|
serializedContexts: '[]'
|
|
})
|
|
}
|
|
)
|
|
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
|
|
item
|
|
)
|
|
harness.assistantDatabase.createTask.mockImplementationOnce(() => {
|
|
throw new Error('会话记录不可用')
|
|
})
|
|
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueEnqueueUser
|
|
)?.(trustedEvent(harness.webContents), input)
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch,
|
|
{ item, input }
|
|
)
|
|
)
|
|
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000747',
|
|
conversationId,
|
|
queueItemId: itemId,
|
|
runtimeSelection: input.runtimeSelection,
|
|
prompt: input.prompt,
|
|
workMode: input.workMode,
|
|
knowledgeLibraryIds: []
|
|
})
|
|
).rejects.toThrow('会话记录不可用')
|
|
|
|
expect(
|
|
harness.assistantDatabase.releaseConversationUserQueueItem
|
|
).toHaveBeenCalledWith(itemId)
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000748',
|
|
conversationId,
|
|
runtimeSelection: { provider: 'auto' },
|
|
prompt: '失败后的新消息',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('dispatches a message queued during a run that later fails', async () => {
|
|
let failRun: ((error: Error) => void) | undefined
|
|
const runtimeStarted = vi.fn()
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
// eslint-disable-next-line require-yield
|
|
async *run(request: { requestId: string }) {
|
|
runtimeStarted(request.requestId)
|
|
throw await new Promise<Error>((resolve) => {
|
|
failRun = resolve
|
|
})
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000751'
|
|
const createItem = (id: string, prompt: string) => {
|
|
const input = {
|
|
conversationId,
|
|
runtimeSelection: { provider: 'auto' as const },
|
|
workMode: 'ask' as const,
|
|
includeMemoryContext: true,
|
|
prompt,
|
|
attachments: [],
|
|
knowledgeLibraryIds: [],
|
|
knowledgeRetrievalMode: 'auto' as const
|
|
}
|
|
return {
|
|
input,
|
|
item: {
|
|
id,
|
|
conversationId,
|
|
source: 'user' as const,
|
|
label: prompt,
|
|
createdAt: '2026-08-21T00:03:00.000Z'
|
|
}
|
|
}
|
|
}
|
|
const first = createItem(
|
|
'00000000-0000-4000-8000-000000000752',
|
|
'会失败的消息'
|
|
)
|
|
const second = createItem(
|
|
'00000000-0000-4000-8000-000000000753',
|
|
'失败之后的新消息'
|
|
)
|
|
harness.assistantDatabase.enqueueConversationUserInput
|
|
.mockReturnValueOnce(first.item)
|
|
.mockReturnValueOnce(second.item)
|
|
harness.assistantDatabase.listConversationQueueItems
|
|
.mockReturnValueOnce([first.item])
|
|
.mockReturnValueOnce([second.item])
|
|
.mockReturnValue([])
|
|
harness.assistantDatabase.claimConversationQueueItem
|
|
.mockReturnValueOnce({
|
|
source: 'user',
|
|
item: first.item,
|
|
payloadJson: JSON.stringify({
|
|
input: first.input,
|
|
serializedContexts: '[]'
|
|
})
|
|
})
|
|
.mockReturnValueOnce({
|
|
source: 'user',
|
|
item: second.item,
|
|
payloadJson: JSON.stringify({
|
|
input: second.input,
|
|
serializedContexts: '[]'
|
|
})
|
|
})
|
|
harness.assistantDatabase.getConversationQueueItem.mockImplementation(
|
|
(id: string) =>
|
|
id === first.item.id ? first.item : second.item
|
|
)
|
|
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueEnqueueUser
|
|
)?.(trustedEvent(harness.webContents), first.input)
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch,
|
|
{ item: first.item, input: first.input }
|
|
)
|
|
)
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000754',
|
|
conversationId,
|
|
queueItemId: first.item.id,
|
|
runtimeSelection: first.input.runtimeSelection,
|
|
prompt: first.input.prompt,
|
|
workMode: first.input.workMode,
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
|
|
|
|
// The next message arrives while the run is still in flight, so the
|
|
// renderer never reports readiness for it afterwards.
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueEnqueueUser
|
|
)?.(trustedEvent(harness.webContents), second.input)
|
|
failRun?.(new Error('模型接口请求失败'))
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(
|
|
'00000000-0000-4000-8000-000000000754',
|
|
'failed',
|
|
expect.any(String)
|
|
)
|
|
)
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch,
|
|
{ item: second.item, input: second.input }
|
|
)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('publishes Runtime usage as context metrics with one settings read', async () => {
|
|
const runtime = {
|
|
runtimeId: 'continue',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
for (const [index, inputTokens] of [100, 120].entries()) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'model-usage',
|
|
callId: `continue-call-${index}`,
|
|
runtime: 'continue',
|
|
provider: 'anthropic',
|
|
model: 'summary-model',
|
|
inputTokens,
|
|
outputTokens: 20,
|
|
cacheReadTokens: 10,
|
|
cacheWriteTokens: 5
|
|
} as const
|
|
}
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
harness.getResolvedSettings.mockResolvedValue({
|
|
toolApproval: 'always',
|
|
subagentSmartRoutingEnabled: false,
|
|
continueModelProfile: {
|
|
contextWindowTokens: 32_000
|
|
},
|
|
contextCompression: {
|
|
triggerTokens: 20_000
|
|
}
|
|
})
|
|
const requestId = '00000000-0000-4000-8000-000000000020'
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'continue-context',
|
|
prompt: 'report context usage',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
const metrics = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, event]) => event)
|
|
.filter(
|
|
(event): event is AgentEvent =>
|
|
(event as AgentEvent).type === 'context-metrics'
|
|
)
|
|
expect(metrics).toEqual([
|
|
{
|
|
requestId,
|
|
type: 'context-metrics',
|
|
contextTokens: 115,
|
|
effectiveTriggerTokens: 32_000,
|
|
contextWindowTokens: 32_000,
|
|
compressionEnabled: false,
|
|
source: 'provider',
|
|
basis: 'model-call'
|
|
},
|
|
{
|
|
requestId,
|
|
type: 'context-metrics',
|
|
contextTokens: 135,
|
|
effectiveTriggerTokens: 32_000,
|
|
contextWindowTokens: 32_000,
|
|
compressionEnabled: false,
|
|
source: 'provider',
|
|
basis: 'model-call'
|
|
}
|
|
])
|
|
expect(harness.getResolvedSettings).toHaveBeenCalledOnce()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('rejects unknown knowledge scope and creates no capability for empty scope', async () => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'capability'),
|
|
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{
|
|
database: {
|
|
listKnowledgeBases: vi.fn(() => [
|
|
{ id: libraryId, name: 'Known' }
|
|
])
|
|
}
|
|
},
|
|
knowledgeGateway
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
|
|
await expect(
|
|
harness.handler?.(event, {
|
|
requestId: '00000000-0000-4000-8000-000000000021',
|
|
conversationId: 'unknown-scope',
|
|
prompt: 'test',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: [
|
|
'22222222-2222-4222-8222-222222222222'
|
|
]
|
|
})
|
|
).rejects.toThrow('不存在的知识库')
|
|
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
|
|
|
|
await harness.handler?.(event, {
|
|
requestId: '00000000-0000-4000-8000-000000000022',
|
|
conversationId: 'empty-scope',
|
|
prompt: 'test',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
'00000000-0000-4000-8000-000000000022',
|
|
'completed'
|
|
)
|
|
)
|
|
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('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
|
|
}> = []
|
|
const runtime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
supportsScopedDataTools: true,
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
}) {
|
|
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 harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
false,
|
|
undefined,
|
|
{ getEnabledBuiltinMcpServerIds }
|
|
)
|
|
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'
|
|
)
|
|
expect(receivedRequests[0]?.knowledgeCapabilityToken).toBeUndefined()
|
|
expect(receivedRequests[1]).toMatchObject({
|
|
knowledgeCapabilityToken: 'browser-capability',
|
|
trustedInstructions: expect.stringContaining('browser_navigate')
|
|
})
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each(['ask', 'execute'] as const)(
|
|
'does not grant or advertise scoped data tools to external OpenCode in %s mode',
|
|
async (workMode) => {
|
|
let receivedRequest:
|
|
| {
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
}
|
|
| undefined
|
|
const externalOpenCode = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
supportsScopedDataTools: false,
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'must-not-be-granted'),
|
|
getAvailableToolNames: vi.fn(() => ['note_list']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
externalOpenCode,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
true
|
|
)
|
|
const requestId = '00000000-0000-4000-8000-000000000025'
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'external-opencode',
|
|
prompt: '读取笔记',
|
|
workMode,
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
|
|
expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined()
|
|
expect(receivedRequest?.trustedInstructions).not.toContain(
|
|
'note_list'
|
|
)
|
|
expect(receivedRequest?.trustedInstructions).not.toContain(
|
|
'Available GoodBuddy data tools:'
|
|
)
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('accepts an authorized knowledge library after the first 100 entries', async () => {
|
|
const libraries = Array.from({ length: 101 }, (_, index) => ({
|
|
id: `00000000-0000-4000-8000-${index
|
|
.toString(16)
|
|
.padStart(12, '0')}`,
|
|
name: `Library ${index}`
|
|
}))
|
|
const listKnowledgeBases = vi.fn(() => libraries)
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'capability'),
|
|
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{ database: { listKnowledgeBases } },
|
|
knowledgeGateway
|
|
)
|
|
const requestId = '00000000-0000-4000-8000-000000000024'
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'later-library',
|
|
prompt: 'search',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: [libraries[100]!.id]
|
|
})
|
|
).resolves.toBeUndefined()
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
expect(listKnowledgeBases).toHaveBeenCalledWith(500)
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
|
requestId,
|
|
[libraries[100]!.id],
|
|
expect.any(AbortSignal),
|
|
'none'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('emits drained knowledge references immediately before done', async () => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
const reference = {
|
|
libraryId,
|
|
libraryName: 'Known',
|
|
documentId: '33333333-3333-4333-8333-333333333333',
|
|
documentName: 'Doc',
|
|
sourceName: 'Source',
|
|
snippet: 'Evidence',
|
|
rank: 1
|
|
}
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
}) {
|
|
expect(request.knowledgeCapabilityToken).toBe('capability')
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'capability'),
|
|
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
|
drainReferences: vi.fn(() => [reference]),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{
|
|
database: {
|
|
listKnowledgeBases: vi.fn(() => [
|
|
{ id: libraryId, name: 'Known' }
|
|
])
|
|
}
|
|
},
|
|
knowledgeGateway
|
|
)
|
|
const requestId = '00000000-0000-4000-8000-000000000023'
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'scoped',
|
|
prompt: 'search',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: [libraryId, libraryId]
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
|
requestId,
|
|
[libraryId],
|
|
expect.any(AbortSignal),
|
|
'none'
|
|
)
|
|
const publicEvents = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, payload]) => payload)
|
|
expect(publicEvents.slice(-2)).toEqual([
|
|
{
|
|
requestId,
|
|
type: 'source-references',
|
|
references: [reference]
|
|
},
|
|
{ requestId, type: 'done' }
|
|
])
|
|
expect(knowledgeGateway.revoke).toHaveBeenCalledWith('capability')
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('coalesces burst deltas while preserving output and terminal order', async () => {
|
|
const deltas = Array.from(
|
|
{ length: 100 },
|
|
(_, index) => `chunk-${index};`
|
|
)
|
|
const expectedOutput = deltas.join('')
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
for (const delta of deltas) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta
|
|
}
|
|
}
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: 'call-burst',
|
|
name: 'read',
|
|
state: 'completed',
|
|
summary: 'read completed'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '00000000-0000-4000-8000-000000000025'
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'burst-stream',
|
|
prompt: 'stream',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
const persistedEvents =
|
|
harness.assistantDatabase.appendTaskEvent.mock.calls
|
|
.filter(([taskId]) => taskId === requestId)
|
|
.map(([, , payload]) => payload)
|
|
const publicEvents = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, payload]) => payload)
|
|
.filter((payload) => payload.requestId === requestId)
|
|
expect(persistedEvents).toHaveLength(3)
|
|
expect(publicEvents).toHaveLength(4)
|
|
expect(persistedEvents.map((event) => event.type)).toEqual([
|
|
'text',
|
|
'tool',
|
|
'done'
|
|
])
|
|
expect(publicEvents.map((event) => event.type)).toEqual([
|
|
'text',
|
|
'text',
|
|
'tool',
|
|
'done'
|
|
])
|
|
expect(publicEvents[0]).toEqual({
|
|
requestId,
|
|
type: 'text',
|
|
delta: deltas[0]
|
|
})
|
|
expect(persistedEvents[0]).toEqual({
|
|
requestId,
|
|
type: 'text',
|
|
delta: expectedOutput
|
|
})
|
|
expect(
|
|
publicEvents
|
|
.filter(
|
|
(
|
|
event
|
|
): event is Extract<AgentEvent, { type: 'text' }> =>
|
|
event.type === 'text'
|
|
)
|
|
.map((event) => event.delta)
|
|
.join('')
|
|
).toBe(expectedOutput)
|
|
expect(
|
|
harness.assistantDatabase.createTextArtifact
|
|
).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('preflights always-retrieve mode and injects bounded untrusted evidence', async () => {
|
|
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,
|
|
sourceId: '55555555-5555-4555-8555-555555555555',
|
|
chunkId,
|
|
documentTitle: '离线部署.md',
|
|
sourceDisplayName: '产品手册',
|
|
sourceType: 'file' as const,
|
|
location: '第 2 节',
|
|
snippet: '离线部署需要先校验安装包',
|
|
relevance: 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
|
|
}
|
|
]
|
|
}
|
|
}
|
|
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,
|
|
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: 1
|
|
}),
|
|
expect.objectContaining({
|
|
type: 'source-references',
|
|
references: [
|
|
expect.objectContaining({
|
|
chunkId,
|
|
documentId
|
|
})
|
|
]
|
|
})
|
|
])
|
|
)
|
|
expect(run).toHaveBeenCalledOnce()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('cancels an active full-library rebuild through its scoped controller', async () => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
let resolveRebuild:
|
|
| ((value: { rebuilt: number; failed: number }) => void)
|
|
| undefined
|
|
const rebuildLibrary = vi.fn(
|
|
() =>
|
|
new Promise<{ rebuilt: number; failed: number }>(
|
|
(resolve) => {
|
|
resolveRebuild = resolve
|
|
}
|
|
)
|
|
)
|
|
const cancelLibraryRebuild = vi.fn(() => true)
|
|
const harness = createHarness(
|
|
{
|
|
capability: 'chat',
|
|
supportsToolExecution: true
|
|
},
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{
|
|
database: { listKnowledgeBases: vi.fn(() => []) },
|
|
cancelLibraryRebuild,
|
|
rebuildLibrary
|
|
}
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
const rebuild = harness.knowledgeRebuildHandler?.(event, {
|
|
knowledgeBaseId: libraryId
|
|
}) as Promise<unknown>
|
|
await vi.waitFor(() => expect(rebuildLibrary).toHaveBeenCalledOnce())
|
|
|
|
expect(
|
|
await Promise.resolve(
|
|
harness.knowledgeCancelRebuildHandler?.(event, libraryId)
|
|
)
|
|
).toBe(true)
|
|
expect(cancelLibraryRebuild).toHaveBeenCalledWith(libraryId)
|
|
resolveRebuild?.({ rebuilt: 0, failed: 0 })
|
|
await expect(rebuild).resolves.toEqual({ rebuilt: 0, failed: 0 })
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('returns no results for an explicitly empty knowledge search scope', async () => {
|
|
const 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
|
|
)
|
|
harness.getResolvedSettings.mockResolvedValueOnce({
|
|
provider: 'opencode',
|
|
defaultModelProfileId: firstProfileId,
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeModelSource: { kind: 'platform' },
|
|
continueModelSource: { kind: 'platform' }
|
|
})
|
|
await expect(
|
|
harness.statusHandler?.(event)
|
|
).resolves.toEqual(
|
|
expect.objectContaining({ label: 'model-two' })
|
|
)
|
|
expect(selectedRuntimes.getStatus).toHaveBeenLastCalledWith({
|
|
provider: 'opencode'
|
|
})
|
|
expect(fallbackRuntime.getStatus).not.toHaveBeenCalled()
|
|
|
|
await Promise.all([
|
|
harness.handler?.(event, {
|
|
requestId: '00000000-0000-4000-8000-000000000011',
|
|
conversationId: 'conversation-one',
|
|
projectId: '00000000-0000-4000-8000-000000000101',
|
|
prompt: 'first request',
|
|
workMode: 'ask',
|
|
runtimeSelection: firstSelection
|
|
}),
|
|
harness.handler?.(event, {
|
|
requestId: '00000000-0000-4000-8000-000000000012',
|
|
conversationId: 'conversation-two',
|
|
prompt: 'second request',
|
|
workMode: 'ask',
|
|
runtimeSelection: secondSelection
|
|
})
|
|
])
|
|
|
|
await vi.waitFor(() => {
|
|
expect(firstRun).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
conversationId: 'conversation-one',
|
|
runtimeSelection: firstSelection
|
|
})
|
|
)
|
|
expect(secondRun).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
conversationId: 'conversation-two',
|
|
runtimeSelection: secondSelection
|
|
})
|
|
)
|
|
})
|
|
expect(fallbackRuntime.run).not.toHaveBeenCalled()
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
|
|
firstSelection,
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
})
|
|
)
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
|
|
secondSelection,
|
|
undefined
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('rejects an active duplicate before resolving Runtime or settings again', async () => {
|
|
let releaseRun!: () => void
|
|
const runReleased = new Promise<void>((resolve) => {
|
|
releaseRun = resolve
|
|
})
|
|
let markRunStarted!: () => void
|
|
const runStarted = new Promise<void>((resolve) => {
|
|
markRunStarted = resolve
|
|
})
|
|
const selectedRuntime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
markRunStarted()
|
|
await runReleased
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => selectedRuntime),
|
|
getStatus: vi.fn(),
|
|
releaseConversation: vi.fn(async () => undefined)
|
|
}
|
|
const getWebSearchCapabilityStatus = vi.fn(async () => ({
|
|
enabled: true
|
|
}))
|
|
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [])
|
|
const harness = createHarness(
|
|
selectedRuntime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
selectedRuntimes,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{
|
|
getWebSearchCapabilityStatus,
|
|
getEnabledBuiltinMcpServerIds
|
|
}
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
const request = {
|
|
requestId: '00000000-0000-4000-8000-000000000013',
|
|
conversationId: 'duplicate-request',
|
|
projectId: '00000000-0000-4000-8000-000000000101',
|
|
prompt: 'run once',
|
|
workMode: 'ask' as const
|
|
}
|
|
|
|
await harness.handler?.(event, request)
|
|
await runStarted
|
|
await expect(harness.handler?.(event, request)).rejects.toThrow(
|
|
'请求正在执行'
|
|
)
|
|
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
|
|
expect(harness.getApplicationSettings).toHaveBeenCalledOnce()
|
|
expect(getWebSearchCapabilityStatus).toHaveBeenCalledOnce()
|
|
expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledOnce()
|
|
expect(harness.contextManager.enrichRequest).toHaveBeenCalledOnce()
|
|
|
|
releaseRun()
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
request.requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('starts independent interactive request lookups concurrently after enrichment', async () => {
|
|
let resolveApplicationSettings!: (value: {
|
|
magicNotesEnabled: boolean
|
|
}) => void
|
|
const applicationSettings = new Promise<{
|
|
magicNotesEnabled: boolean
|
|
}>((resolve) => {
|
|
resolveApplicationSettings = resolve
|
|
})
|
|
let resolveWebSearch!: (value: { enabled: boolean }) => void
|
|
const webSearch = new Promise<{ enabled: boolean }>((resolve) => {
|
|
resolveWebSearch = resolve
|
|
})
|
|
let resolveRuntimeSettings!: (value: {
|
|
workspacePath: string
|
|
}) => void
|
|
const runtimeSettings = new Promise<{
|
|
workspacePath: string
|
|
}>((resolve) => {
|
|
resolveRuntimeSettings = resolve
|
|
})
|
|
let resolveBuiltinMcpServers!: (value: never[]) => void
|
|
const builtinMcpServers = new Promise<never[]>((resolve) => {
|
|
resolveBuiltinMcpServers = resolve
|
|
})
|
|
const getWebSearchCapabilityStatus = vi.fn(() => webSearch)
|
|
const getEnabledBuiltinMcpServerIds = vi.fn(
|
|
() => builtinMcpServers
|
|
)
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const goodbuddyConfigService = {
|
|
takePendingReload: vi.fn(() => 'none'),
|
|
revokeRequest: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
goodbuddyConfigService,
|
|
{
|
|
getWebSearchCapabilityStatus,
|
|
getEnabledBuiltinMcpServerIds
|
|
}
|
|
)
|
|
harness.getApplicationSettings.mockReturnValueOnce(
|
|
applicationSettings
|
|
)
|
|
harness.getResolvedSettings.mockReturnValueOnce(runtimeSettings)
|
|
const requestId = '00000000-0000-4000-8000-000000000014'
|
|
const operation = harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
{
|
|
requestId,
|
|
conversationId: 'concurrent-request-setup',
|
|
prompt: 'prepare concurrently',
|
|
workMode: 'ask',
|
|
knowledgeLibraryIds: []
|
|
}
|
|
)
|
|
|
|
await vi.waitFor(() => {
|
|
expect(harness.getApplicationSettings).toHaveBeenCalledOnce()
|
|
expect(getWebSearchCapabilityStatus).toHaveBeenCalledOnce()
|
|
expect(harness.getResolvedSettings).toHaveBeenCalledOnce()
|
|
expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledOnce()
|
|
})
|
|
const enrichmentOrder =
|
|
harness.contextManager.enrichRequest.mock.invocationCallOrder[0]
|
|
if (enrichmentOrder === undefined) {
|
|
throw new Error('Request was not enriched')
|
|
}
|
|
for (const lookup of [
|
|
harness.getApplicationSettings,
|
|
getWebSearchCapabilityStatus,
|
|
harness.getResolvedSettings,
|
|
getEnabledBuiltinMcpServerIds
|
|
]) {
|
|
const lookupOrder = lookup.mock.invocationCallOrder[0]
|
|
if (lookupOrder === undefined) {
|
|
throw new Error('Interactive request lookup did not start')
|
|
}
|
|
expect(enrichmentOrder).toBeLessThan(lookupOrder)
|
|
}
|
|
|
|
resolveApplicationSettings({ magicNotesEnabled: false })
|
|
resolveWebSearch({ enabled: false })
|
|
resolveRuntimeSettings({ workspacePath: 'C:\\Workspace' })
|
|
resolveBuiltinMcpServers([])
|
|
await expect(operation).resolves.toBeUndefined()
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('aborts active work and clears browser sessions before assistant data', async () => {
|
|
const lifecycle: string[] = []
|
|
let markStarted!: () => void
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve
|
|
})
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(
|
|
_request: unknown,
|
|
signal: AbortSignal
|
|
): AsyncGenerator<never, void, void> {
|
|
markStarted()
|
|
await new Promise<void>((_resolve, reject) => {
|
|
signal.addEventListener(
|
|
'abort',
|
|
() => {
|
|
lifecycle.push('aborted')
|
|
reject(signal.reason)
|
|
},
|
|
{ once: true }
|
|
)
|
|
})
|
|
yield undefined as never
|
|
}
|
|
}
|
|
const harness = createHarness(runtime, async () => {
|
|
lifecycle.push('browser-cleared')
|
|
})
|
|
vi.mocked(
|
|
harness.assistantDatabase.clearAssistantData
|
|
).mockImplementation(() => {
|
|
lifecycle.push('assistant-cleared')
|
|
})
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-clear',
|
|
prompt: 'keep running',
|
|
workMode: 'execute'
|
|
})
|
|
await started
|
|
|
|
await harness.clearHandler?.(trustedEvent(harness.webContents))
|
|
|
|
expect(lifecycle).toEqual([
|
|
'aborted',
|
|
'browser-cleared',
|
|
'assistant-cleared'
|
|
])
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('coalesces concurrent local-data clear requests', async () => {
|
|
let releaseClear!: () => void
|
|
const clearBlocked = new Promise<void>((resolve) => {
|
|
releaseClear = resolve
|
|
})
|
|
const onBeforeClearLocalData = vi.fn(async () => {
|
|
await clearBlocked
|
|
})
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn()
|
|
}
|
|
const harness = createHarness(runtime, onBeforeClearLocalData)
|
|
const event = trustedEvent(harness.webContents)
|
|
|
|
const firstClear = harness.clearHandler?.(event)
|
|
const secondClear = harness.clearHandler?.(event)
|
|
|
|
expect(firstClear).toBe(secondClear)
|
|
await vi.waitFor(() => {
|
|
expect(onBeforeClearLocalData).toHaveBeenCalledOnce()
|
|
})
|
|
await expect(
|
|
harness.handler?.(event, {
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-during-clear',
|
|
prompt: 'do not start',
|
|
workMode: 'execute'
|
|
})
|
|
).rejects.toThrow('本地数据维护期间暂不接受新任务')
|
|
|
|
releaseClear()
|
|
await expect(firstClear).resolves.toBeUndefined()
|
|
expect(
|
|
harness.assistantDatabase.clearAssistantData
|
|
).toHaveBeenCalledOnce()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('marks a request failed when a tool fails before runtime done', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: 'call-1',
|
|
name: 'write',
|
|
state: 'failed',
|
|
summary: 'OpenCode 工具:write',
|
|
error: 'write path denied'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'write a file',
|
|
workMode: 'execute'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
'write 工具执行失败:write path denied'
|
|
)
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(requestId, 'completed')
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({
|
|
requestId,
|
|
type: 'error',
|
|
status: 'failed',
|
|
message: 'write 工具执行失败:write path denied'
|
|
})
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('allows a completed request after a recoverable tool failure', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: 'call-recoverable',
|
|
name: '浏览器输入',
|
|
state: 'recoverable',
|
|
summary: '直连模型工具需要刷新后重试:浏览器输入'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'retry browser input',
|
|
workMode: 'execute'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
expect.any(String)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('does not fail a completed native subagent for its provisional Task tool', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: 'call-task-1',
|
|
name: 'task',
|
|
state: 'running',
|
|
summary: 'OpenCode 工具:task'
|
|
}
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'subagent',
|
|
childTaskId: '00000000-0000-4000-8000-000000000601',
|
|
expertId: '00000000-0000-4000-8000-000000000701',
|
|
expertName: 'explore',
|
|
routingMode: 'native',
|
|
runtimeCallId: 'call-task-1',
|
|
state: 'completed',
|
|
output: 'Desktop files inspected.'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'inspect the desktop',
|
|
workMode: 'execute'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
expect.any(String)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each(['opencode', 'continue'] as const)(
|
|
'preserves read-only Ask mode at the %s Runtime boundary',
|
|
async (runtimeId) => {
|
|
let received:
|
|
| {
|
|
request: { workMode?: string }
|
|
authorize: unknown
|
|
}
|
|
| undefined
|
|
const runtime = {
|
|
runtimeId,
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(
|
|
request: { requestId: string; workMode?: string },
|
|
_signal: AbortSignal,
|
|
authorize: unknown
|
|
) {
|
|
received = { request, authorize }
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'run the task',
|
|
workMode: 'ask'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
expect(received?.request.workMode).toBe('ask')
|
|
expect(received?.authorize).toBeUndefined()
|
|
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
|
expect(
|
|
harness.assistantDatabase.createTask
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: requestId, workMode: 'ask' })
|
|
)
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('keeps Ask fail-closed and auto-allows DeepSeek Harness Execute tools', async () => {
|
|
const receivedAuthorizers: unknown[] = []
|
|
const executeDecisions: string[] = []
|
|
const runtime = {
|
|
runtimeId: 'deepseek-harness',
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(
|
|
request: { requestId: string; workMode?: string },
|
|
_signal: AbortSignal,
|
|
authorize?: (request: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}) => Promise<string>
|
|
) {
|
|
receivedAuthorizers.push(authorize)
|
|
if (request.workMode === 'execute') {
|
|
executeDecisions.push(
|
|
(await authorize?.({
|
|
scopeKey: 'deepseek-harness:write_file',
|
|
title: '写入文件',
|
|
description: '主机工具执行'
|
|
})) ?? 'missing'
|
|
)
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
harness.approvalBroker.request.mockResolvedValue('once')
|
|
|
|
for (const [index, workMode] of (
|
|
['ask', 'execute'] as const
|
|
).entries()) {
|
|
const requestId = `3f496642-f47d-4e0a-8944-a32c77b0d6e${index}`
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: `conversation-${index}`,
|
|
prompt: 'run the task',
|
|
workMode
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
}
|
|
|
|
expect(receivedAuthorizers).toEqual([
|
|
expect.any(Function),
|
|
expect.any(Function)
|
|
])
|
|
expect(executeDecisions).toEqual(['once'])
|
|
await expect(
|
|
(
|
|
receivedAuthorizers[0] as (
|
|
request: Record<string, string>
|
|
) => Promise<string>
|
|
)({
|
|
scopeKey: 'deepseek-harness:write_file',
|
|
title: '写入文件',
|
|
description: 'must be denied'
|
|
})
|
|
).resolves.toBe('deny')
|
|
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each(['model', 'opencode'] as const)(
|
|
'normalizes legacy interactive Plan requests to Ask for %s',
|
|
async (runtimeId) => {
|
|
let receivedRequest:
|
|
| { requestId: string; prompt: string; workMode?: string }
|
|
| undefined
|
|
const runtime = {
|
|
runtimeId,
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: {
|
|
requestId: string
|
|
prompt: string
|
|
workMode?: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'draft a plan',
|
|
workMode: 'plan'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
expect(receivedRequest?.workMode).toBe('ask')
|
|
expect(receivedRequest?.prompt).toContain('Work mode: Ask.')
|
|
expect(receivedRequest?.prompt).not.toContain('Work mode: Plan.')
|
|
expect(
|
|
harness.assistantDatabase.createTask
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: requestId, workMode: 'ask' })
|
|
)
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('routes eligible Ask requests through the persisted smart expert service and publishes child events', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
run: vi.fn()
|
|
}
|
|
const childTaskId = '00000000-0000-4000-8000-000000000099'
|
|
const expert = {
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
name: '研究专家',
|
|
description: '',
|
|
systemInstructions: 'Analyze evidence.',
|
|
routingKeywords: ['资料分析'],
|
|
enabled: true,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z'
|
|
}
|
|
const subagentService = {
|
|
run: vi.fn(async (input: {
|
|
parentRequest: { requestId: string }
|
|
onEvent: (event: Record<string, unknown>) => void
|
|
}) => {
|
|
for (const state of ['queued', 'running', 'completed']) {
|
|
input.onEvent({
|
|
requestId: input.parentRequest.requestId,
|
|
type: 'subagent',
|
|
childTaskId,
|
|
expertId: expert.id,
|
|
expertName: expert.name,
|
|
routingMode: 'smart',
|
|
state
|
|
})
|
|
}
|
|
return { childTaskId, output: '专家结果' }
|
|
}),
|
|
cancelAll: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
subagentService,
|
|
true
|
|
)
|
|
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
|
|
expert
|
|
])
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-smart',
|
|
prompt: '请做资料分析',
|
|
workMode: 'ask',
|
|
smartRouting: true
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
expect(runtime.run).not.toHaveBeenCalled()
|
|
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
|
|
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
|
expect(subagentService.run).toHaveBeenCalledWith(
|
|
expect.objectContaining({ expert, routingMode: 'smart' })
|
|
)
|
|
expect(harness.assistantDatabase.appendTaskEvent).toHaveBeenCalledWith(
|
|
requestId,
|
|
'subagent',
|
|
expect.objectContaining({ childTaskId, state: 'queued' })
|
|
)
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({ type: 'subagent', state: 'completed' })
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each([
|
|
{ workMode: 'ask' as const, persisted: false },
|
|
{ workMode: 'execute' as const, persisted: true }
|
|
])(
|
|
'falls back to the ordinary runtime for ineligible smart routing %#',
|
|
async ({ workMode, persisted }) => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const run = vi.spyOn(runtime, 'run')
|
|
const subagentService = {
|
|
run: vi.fn(),
|
|
cancelAll: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
subagentService,
|
|
persisted
|
|
)
|
|
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
name: '研究专家',
|
|
description: '',
|
|
systemInstructions: 'Analyze.',
|
|
routingKeywords: ['资料分析'],
|
|
enabled: true,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z'
|
|
}
|
|
])
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-fallback',
|
|
prompt: '请做资料分析',
|
|
workMode,
|
|
smartRouting: true
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
expect(run).toHaveBeenCalledOnce()
|
|
expect(subagentService.run).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('does not fall back to the ordinary runtime after smart subagent cancellation', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
run: vi.fn()
|
|
}
|
|
let markStarted!: () => void
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve
|
|
})
|
|
const subagentService = {
|
|
run: vi.fn((input: { signal: AbortSignal }) => {
|
|
markStarted()
|
|
return new Promise((_resolve, reject) => {
|
|
input.signal.addEventListener(
|
|
'abort',
|
|
() => reject(input.signal.reason),
|
|
{ once: true }
|
|
)
|
|
})
|
|
}),
|
|
cancelAll: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
subagentService,
|
|
true
|
|
)
|
|
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
name: '研究专家',
|
|
description: '',
|
|
systemInstructions: 'Analyze.',
|
|
routingKeywords: ['资料分析'],
|
|
enabled: true,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z'
|
|
}
|
|
])
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-cancel-smart',
|
|
prompt: '请做资料分析',
|
|
workMode: 'ask',
|
|
smartRouting: true
|
|
})
|
|
await started
|
|
harness.cancelHandler?.(trustedEvent(harness.webContents), requestId)
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'cancelled',
|
|
'请求已取消'
|
|
)
|
|
)
|
|
expect(runtime.run).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('rejects Execute before creating a task on an unsupported runtime', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: false,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
run: vi.fn()
|
|
}
|
|
const harness = createHarness(runtime)
|
|
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'write a file',
|
|
workMode: 'execute'
|
|
})
|
|
).rejects.toThrow('当前 Runtime 不支持工具执行')
|
|
expect(harness.assistantDatabase.createTask).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('bridges channel ask requests to read-only tasks without approval', async () => {
|
|
let received:
|
|
| {
|
|
request: {
|
|
requestId: string
|
|
conversationId: string
|
|
prompt: string
|
|
workMode: string
|
|
}
|
|
authorize?: (request: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}) => Promise<string>
|
|
}
|
|
| undefined
|
|
const runtime = {
|
|
capability: 'chat',
|
|
async *run(
|
|
request: {
|
|
requestId: string
|
|
conversationId: string
|
|
prompt: string
|
|
workMode: string
|
|
},
|
|
_signal: AbortSignal,
|
|
authorize?: (request: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}) => Promise<string>
|
|
) {
|
|
received = { request, authorize }
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: '只读结果'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-1',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-1',
|
|
conversationType: 'direct',
|
|
text: '请只读分析',
|
|
mentioned: false,
|
|
workMode: 'ask'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toEqual({
|
|
status: 'completed',
|
|
output: '只读结果'
|
|
})
|
|
expect(received?.request).toMatchObject({
|
|
workMode: 'ask',
|
|
prompt: expect.stringContaining('请只读分析')
|
|
})
|
|
await expect(
|
|
received?.authorize?.({
|
|
scopeKey: 'model:builtin:workspace_read_text',
|
|
title: '读取文件',
|
|
description: '不应申请批准'
|
|
})
|
|
).resolves.toBe('deny')
|
|
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
|
expect(harness.getPolicySettings).not.toHaveBeenCalled()
|
|
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
|
expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: '企业微信远程请求',
|
|
instructions: '请只读分析',
|
|
workMode: 'ask',
|
|
origin: 'delegation'
|
|
})
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each([
|
|
['weixin', '微信 ClawBot'],
|
|
['wecom', '企业微信'],
|
|
['dingtalk', '钉钉']
|
|
] as const)(
|
|
'grants read-only Magic Notes tools to %s channel Ask requests',
|
|
async (channel, channelLabel) => {
|
|
let receivedRequest:
|
|
| {
|
|
knowledgeCapabilityToken?: string
|
|
prompt: string
|
|
trustedInstructions?: string
|
|
workMode: string
|
|
}
|
|
| undefined
|
|
const runtime = {
|
|
capability: 'chat',
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
prompt: string
|
|
trustedInstructions?: string
|
|
workMode: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: `call-${channel}-note-list`,
|
|
name: 'note_list',
|
|
state: 'completed',
|
|
summary: '读取笔记列表'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn<KnowledgeGrantMock>(() =>
|
|
'channel-notes-capability'
|
|
),
|
|
getAvailableToolNames: vi.fn(() => [
|
|
'note_list',
|
|
'note_get',
|
|
'note_search'
|
|
]),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
true
|
|
)
|
|
vi.mocked(
|
|
harness.assistantDatabase.listProjects
|
|
).mockReturnValue([
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000401',
|
|
name: channelLabel,
|
|
description: `${channelLabel}远程消息与受控任务`,
|
|
rootPath: 'C:\\ProjectWorkspace',
|
|
executionSpace: {
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
},
|
|
defaultWorkMode: 'ask',
|
|
runtimeSelection: {
|
|
provider: 'model',
|
|
profileId: '00000000-0000-4000-8000-000000000001'
|
|
},
|
|
kind: 'channel',
|
|
channel,
|
|
status: 'active',
|
|
createdAt: '2026-08-04T00:00:00.000Z',
|
|
updatedAt: '2026-08-04T00:00:00.000Z'
|
|
}
|
|
])
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel,
|
|
eventId: `event-${channel}-notes`,
|
|
senderId: 'user-1',
|
|
conversationId: `conversation-${channel}-notes`,
|
|
conversationType: 'direct',
|
|
text: '读取我的笔记',
|
|
mentioned: false,
|
|
workMode: 'ask'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toMatchObject({ status: 'completed' })
|
|
const requestId =
|
|
vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0]
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
|
requestId,
|
|
[],
|
|
expect.any(AbortSignal),
|
|
'read'
|
|
)
|
|
expect(receivedRequest).toMatchObject({
|
|
knowledgeCapabilityToken: 'channel-notes-capability',
|
|
workMode: 'ask',
|
|
trustedInstructions: expect.stringContaining(
|
|
'note_list, note_get, note_search'
|
|
)
|
|
})
|
|
expect(receivedRequest?.prompt).toContain('读取我的笔记')
|
|
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
|
|
'channel-notes-capability'
|
|
)
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('persists remote media and passes it through the existing context path', async () => {
|
|
let receivedRequest:
|
|
| {
|
|
contextIds?: string[]
|
|
prompt: string
|
|
}
|
|
| undefined
|
|
const runtime = {
|
|
capability: 'chat',
|
|
async *run(request: {
|
|
requestId: string
|
|
contextIds?: string[]
|
|
prompt: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: '图片已分析'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-media',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-media',
|
|
conversationType: 'direct',
|
|
text: '',
|
|
attachments: [
|
|
{
|
|
name: '现场.png',
|
|
mimeType: 'image/png',
|
|
size: 4,
|
|
kind: 'image',
|
|
dataBase64: 'iVBORw=='
|
|
}
|
|
],
|
|
mentioned: false,
|
|
workMode: 'ask'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toEqual({
|
|
status: 'completed',
|
|
output: '图片已分析'
|
|
})
|
|
expect(
|
|
harness.contextManager.ingestRemoteAttachment
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: '现场.png' })
|
|
)
|
|
expect(receivedRequest?.contextIds).toEqual([
|
|
'00000000-0000-4000-8000-000000000498'
|
|
])
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteConversationMessage
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
role: 'user',
|
|
content: '请分析我发送的附件。',
|
|
attachments: [
|
|
expect.objectContaining({ name: '现场.png' })
|
|
]
|
|
})
|
|
)
|
|
expect(harness.contextManager.remove).toHaveBeenCalledWith(
|
|
'00000000-0000-4000-8000-000000000498'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('returns a generated image only as a current-task channel attachment', async () => {
|
|
const image = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
|
])
|
|
const runtime = {
|
|
capability: 'image-generation',
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'generated-image',
|
|
mimeType: 'image/png',
|
|
data: image.toString('base64'),
|
|
title: '结果图'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-generated-image',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-generated-image',
|
|
conversationType: 'direct',
|
|
text: '生成结果图',
|
|
mentioned: false,
|
|
workMode: 'ask'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toMatchObject({
|
|
status: 'completed',
|
|
attachments: [
|
|
{
|
|
name: '结果图.png',
|
|
mimeType: 'image/png',
|
|
size: image.byteLength,
|
|
kind: 'image',
|
|
dataBase64: image.toString('base64')
|
|
}
|
|
],
|
|
artifactIds: [
|
|
'00000000-0000-4000-8000-000000000499'
|
|
]
|
|
})
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteConversationMessage
|
|
).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
role: 'assistant',
|
|
artifactIds: [
|
|
'00000000-0000-4000-8000-000000000499'
|
|
]
|
|
})
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('creates a bounded result file only when the remote user explicitly requests one', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: '# 本周报告\n\n已完成。'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
const result = await executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-result-file',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-result-file',
|
|
conversationType: 'direct',
|
|
text: '请生成一个文件,总结本周进展',
|
|
mentioned: false,
|
|
workMode: 'ask'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
expect(result).toMatchObject({
|
|
status: 'completed',
|
|
output: '# 本周报告\n\n已完成。',
|
|
attachments: [
|
|
{
|
|
name: 'GoodBuddy-结果.md',
|
|
mimeType: 'text/markdown',
|
|
kind: 'file'
|
|
}
|
|
]
|
|
})
|
|
expect(
|
|
Buffer.from(
|
|
result.attachments?.[0]?.dataBase64 ?? '',
|
|
'base64'
|
|
).toString('utf8')
|
|
).toBe('# 本周报告\n\n已完成。')
|
|
expect(
|
|
harness.assistantDatabase.createTextArtifact
|
|
).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
name: 'Runtime status resolution fails',
|
|
getStatus: () => Promise.reject(new Error('状态查询失败')),
|
|
expectedError: '状态查询失败'
|
|
},
|
|
{
|
|
name: 'Runtime is available without tool execution',
|
|
getStatus: () =>
|
|
Promise.resolve({
|
|
id: 'model',
|
|
label: 'Direct model',
|
|
available: true,
|
|
supportsToolExecution: false
|
|
}),
|
|
expectedError:
|
|
'所选处理后端不支持工具执行,请在消息通道设置中选择 OpenCode、Continue 或支持工具的直连模型'
|
|
}
|
|
])(
|
|
'finalizes remote Execute consistently when $name',
|
|
async ({ getStatus, expectedError }) => {
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(getStatus),
|
|
run: vi.fn()
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-execute-unavailable',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-execute-unavailable',
|
|
conversationType: 'direct',
|
|
text: '/execute 更新 README',
|
|
mentioned: false,
|
|
workMode: 'ask'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toEqual({
|
|
status: 'failed',
|
|
error: expectedError
|
|
})
|
|
|
|
const remoteTask =
|
|
harness.assistantDatabase.createTask.mock.calls[0]?.[0]
|
|
expect(remoteTask).toEqual(
|
|
expect.objectContaining({
|
|
projectId: '00000000-0000-4000-8000-000000000401',
|
|
conversationId: '00000000-0000-4000-8000-000000000402',
|
|
workMode: 'execute',
|
|
origin: 'delegation',
|
|
visible: false
|
|
})
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(remoteTask?.id, 'failed', expectedError)
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteConversationMessage
|
|
).toHaveBeenLastCalledWith({
|
|
conversationId: '00000000-0000-4000-8000-000000000402',
|
|
role: 'assistant',
|
|
content: expectedError,
|
|
status: '执行不可用'
|
|
})
|
|
const activities = harness.webContents.send.mock.calls
|
|
.filter(
|
|
([channel]) =>
|
|
channel === ipcChannels.remoteChannelActivity
|
|
)
|
|
.map(([, activity]) => activity)
|
|
expect(activities).toHaveLength(2)
|
|
expect(activities[0]).toEqual(
|
|
expect.objectContaining({
|
|
requestId: remoteTask?.id,
|
|
kind: 'request',
|
|
status: 'running'
|
|
})
|
|
)
|
|
expect(activities[1]).toEqual({
|
|
requestId: remoteTask?.id,
|
|
conversationId: '00000000-0000-4000-8000-000000000402',
|
|
projectId: '00000000-0000-4000-8000-000000000401',
|
|
projectName: '企业微信',
|
|
channel: 'wecom',
|
|
kind: 'result',
|
|
title: '企业微信远程执行不可用',
|
|
detail: expectedError,
|
|
status: 'failed'
|
|
})
|
|
expect(runtime.run).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('runs remote Execute immediately with the selected direct model policy', async () => {
|
|
let authorization: string | undefined
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(async () => ({
|
|
id: 'model',
|
|
label: 'Direct model',
|
|
available: true,
|
|
supportsToolExecution: true
|
|
})),
|
|
async *run(
|
|
request: { requestId: string },
|
|
_signal: AbortSignal,
|
|
authorize: (
|
|
request: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}
|
|
) => Promise<string>
|
|
) {
|
|
authorization = await authorize({
|
|
scopeKey: 'model:builtin:workspace_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('grants Magic Notes write tools to channel Execute requests', async () => {
|
|
let receivedRequest:
|
|
| {
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
workMode: string
|
|
}
|
|
| undefined
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(async () => ({
|
|
id: 'model',
|
|
label: 'Direct model',
|
|
available: true,
|
|
supportsToolExecution: true
|
|
})),
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
workMode: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn<KnowledgeGrantMock>(() =>
|
|
'channel-notes-write-capability'
|
|
),
|
|
getAvailableToolNames: vi.fn(() => [
|
|
'note_list',
|
|
'note_get',
|
|
'note_search',
|
|
'note_create',
|
|
'note_update',
|
|
'note_entry_create',
|
|
'note_entry_update',
|
|
'note_entry_delete',
|
|
'note_delete'
|
|
]),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
true
|
|
)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-execute-notes',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-execute-notes',
|
|
conversationType: 'direct',
|
|
text: '/execute 创建一条笔记',
|
|
mentioned: false,
|
|
workMode: 'ask'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toMatchObject({ status: 'completed' })
|
|
const requestId =
|
|
vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0]
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
|
requestId,
|
|
[],
|
|
expect.any(AbortSignal),
|
|
'write'
|
|
)
|
|
expect(receivedRequest).toMatchObject({
|
|
knowledgeCapabilityToken: 'channel-notes-write-capability',
|
|
workMode: 'execute',
|
|
trustedInstructions: expect.stringContaining('note_create')
|
|
})
|
|
expect(receivedRequest?.trustedInstructions).toContain(
|
|
'note_delete'
|
|
)
|
|
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
|
|
'channel-notes-write-capability'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('does not grant or advertise Magic Notes to external OpenCode channels', async () => {
|
|
let receivedRequest:
|
|
| {
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
}
|
|
| undefined
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
supportsScopedDataTools: false,
|
|
getStatus: vi.fn(async () => ({
|
|
id: 'opencode',
|
|
label: 'External OpenCode',
|
|
available: true,
|
|
supportsToolExecution: true
|
|
})),
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn<KnowledgeGrantMock>(() => 'must-not-be-granted'),
|
|
getAvailableToolNames: vi.fn(() => ['note_list']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
selectedRuntime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
true
|
|
)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
const result = await executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-external-opencode',
|
|
senderId: 'friend',
|
|
conversationId: 'external-opencode',
|
|
conversationType: 'direct',
|
|
text: '读取我的笔记',
|
|
mentioned: true,
|
|
workMode: 'ask'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
if (result.status === 'failed') {
|
|
throw new Error(result.error)
|
|
}
|
|
expect(result).toMatchObject({ status: 'completed' })
|
|
|
|
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
|
|
expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined()
|
|
expect(receivedRequest?.trustedInstructions).not.toContain(
|
|
'note_list'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('advertises only native read access to remote Agent Runtime Ask requests', async () => {
|
|
let receivedRequest:
|
|
| {
|
|
trustedInstructions?: string
|
|
workMode?: string
|
|
}
|
|
| undefined
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: {
|
|
requestId: string
|
|
trustedInstructions?: string
|
|
workMode?: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => selectedRuntime),
|
|
getStatus: vi.fn(),
|
|
releaseConversation: vi.fn(async () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
{
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
run: vi.fn()
|
|
},
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
const projectId = '00000000-0000-4000-8000-000000000451'
|
|
harness.getApplicationSettings.mockResolvedValue({
|
|
magicNotesEnabled: false,
|
|
remoteProjectsEnabled: true
|
|
} as never)
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
id: projectId,
|
|
name: 'Remote project',
|
|
description: '',
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000452',
|
|
remoteRootPath: '/srv/project'
|
|
},
|
|
defaultWorkMode: 'ask',
|
|
runtimeSelection: { provider: 'opencode' },
|
|
kind: 'user',
|
|
status: 'active',
|
|
createdAt: '2026-08-25T00:00:00.000Z',
|
|
updatedAt: '2026-08-25T00:00:00.000Z'
|
|
} as never)
|
|
|
|
await harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
{
|
|
requestId: '00000000-0000-4000-8000-000000000453',
|
|
conversationId: 'conversation-remote-ask',
|
|
projectId,
|
|
prompt: 'Read marker.txt',
|
|
workMode: 'ask',
|
|
runtimeSelection: { provider: 'opencode' },
|
|
knowledgeLibraryIds: []
|
|
}
|
|
)
|
|
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
|
|
{ provider: 'opencode' },
|
|
expect.objectContaining({
|
|
kind: 'ssh',
|
|
remoteRootPath: '/srv/project'
|
|
})
|
|
)
|
|
expect(receivedRequest).toMatchObject({ workMode: 'ask' })
|
|
expect(receivedRequest?.trustedInstructions).toContain(
|
|
'only the native read tool'
|
|
)
|
|
expect(receivedRequest?.trustedInstructions).toContain(
|
|
'Do not call any other tool or make changes'
|
|
)
|
|
expect(receivedRequest?.trustedInstructions).not.toContain(
|
|
'Do not call tools'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('routes remote Execute to a configured Agent Runtime without a GoodBuddy approval callback', async () => {
|
|
let receivedAuthorize: unknown = 'not-called'
|
|
const configuredProfileId =
|
|
'00000000-0000-4000-8000-000000000019'
|
|
const selectedRuntime = {
|
|
runtimeId: 'continue',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(async () => ({
|
|
id: 'continue',
|
|
label: 'Continue',
|
|
available: true,
|
|
supportsToolExecution: true
|
|
})),
|
|
async *run(
|
|
request: { requestId: string },
|
|
_signal: AbortSignal,
|
|
authorize?: unknown
|
|
) {
|
|
receivedAuthorize = authorize
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: 'Continue 已执行'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => selectedRuntime),
|
|
getStatus: vi.fn(),
|
|
releaseConversation: vi.fn(async () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
{
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
run: vi.fn()
|
|
},
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
harness.getResolvedSettings.mockResolvedValue({
|
|
toolApproval: 'always',
|
|
subagentSmartRoutingEnabled: false,
|
|
continueModelProfile: { id: configuredProfileId },
|
|
modelProfiles: [{ id: configuredProfileId }]
|
|
})
|
|
vi.mocked(
|
|
harness.assistantDatabase.listProjects
|
|
).mockReturnValue([
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000401',
|
|
name: '企业微信',
|
|
description: '企业微信远程消息与受控任务',
|
|
rootPath: 'C:\\ProjectWorkspace',
|
|
executionSpace: {
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
},
|
|
defaultWorkMode: 'execute',
|
|
runtimeSelection: { provider: 'continue' },
|
|
kind: 'channel',
|
|
channel: 'wecom',
|
|
status: 'active',
|
|
createdAt: '2026-08-04T00:00:00.000Z',
|
|
updatedAt: '2026-08-04T00:00:00.000Z'
|
|
}
|
|
])
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-execute-runtime',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-execute-runtime',
|
|
conversationType: 'direct',
|
|
text: '更新 README',
|
|
mentioned: false,
|
|
workMode: 'ask'
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toEqual({
|
|
status: 'completed',
|
|
output: 'Continue 已执行'
|
|
})
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
|
|
{ provider: 'continue', profileId: configuredProfileId },
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
})
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.getOrCreateRemoteConversation
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
runtimeSelection: { provider: 'continue' }
|
|
})
|
|
)
|
|
expect(receivedAuthorize).toBeUndefined()
|
|
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('stops channels before clearing other IPC resources', async () => {
|
|
const order: string[] = []
|
|
channelMocks.stop.mockImplementationOnce(async () => {
|
|
order.push('channel-stop')
|
|
})
|
|
const harness = createHarness({
|
|
capability: 'chat',
|
|
run: vi.fn()
|
|
})
|
|
harness.contextManager.clear.mockImplementation(() => {
|
|
order.push('context-clear')
|
|
})
|
|
|
|
await harness.dispose()
|
|
|
|
expect(order).toEqual(['channel-stop', 'context-clear'])
|
|
})
|
|
|
|
it('authorizes direct-model Execute tools without approval events or broker prompts', async () => {
|
|
let receivedAuthorize:
|
|
| ((
|
|
request: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}
|
|
) => Promise<string>)
|
|
| undefined
|
|
let decision: string | undefined
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(
|
|
request: { requestId: string },
|
|
_signal: AbortSignal,
|
|
authorize: typeof receivedAuthorize
|
|
) {
|
|
receivedAuthorize = authorize
|
|
decision = await authorize?.({
|
|
scopeKey: 'model:builtin:workspace_read_text',
|
|
title: '允许读取工作区文本?',
|
|
description: '读取 README.md'
|
|
})
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: 'call-1',
|
|
name: '读取工作区文本',
|
|
state: 'completed',
|
|
summary: '直连模型工具已完成'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: '读取文件',
|
|
workMode: 'execute'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
expect(receivedAuthorize).toEqual(expect.any(Function))
|
|
expect(decision).toBe('once')
|
|
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
|
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
|
|
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(requestId, 'waiting_approval')
|
|
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({ type: 'approval' })
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('denies direct-model Execute tools when the deny-all policy is selected', async () => {
|
|
let decision: string | undefined
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(
|
|
request: { requestId: string },
|
|
_signal: AbortSignal,
|
|
authorize: (
|
|
request: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}
|
|
) => Promise<string>
|
|
) {
|
|
decision = await authorize({
|
|
scopeKey: 'model:builtin:workspace_read_text',
|
|
title: '允许读取工作区文本?',
|
|
description: '读取 README.md'
|
|
})
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime, undefined, 'policy')
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: '读取文件',
|
|
workMode: 'execute'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
expect(decision).toBe('deny')
|
|
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
|
expect(harness.getPolicySettings).toHaveBeenCalledOnce()
|
|
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
|
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({ type: 'approval' })
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('routes local config apply through native approval and denies policy mode', async () => {
|
|
for (const [toolApproval, expectedAuthorized] of [
|
|
['always', true],
|
|
['policy', false]
|
|
] as const) {
|
|
let authorizeConfigApply:
|
|
| ((
|
|
event: {
|
|
requestId: string
|
|
planId: string
|
|
summary: string
|
|
risk: 'high'
|
|
reload: 'after-current-request'
|
|
destructive: boolean
|
|
},
|
|
signal: AbortSignal
|
|
) => Promise<boolean>)
|
|
| undefined
|
|
const requestId = `3f496642-f47d-4e0a-8944-a32c77b0d6e${expectedAuthorized ? '1' : '2'}`
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(
|
|
(
|
|
_requestId: string,
|
|
_libraryIds: readonly string[],
|
|
_signal: AbortSignal,
|
|
_magicNotesAccess: string,
|
|
config: {
|
|
authorizeApply?: typeof authorizeConfigApply
|
|
}
|
|
) => {
|
|
authorizeConfigApply = config.authorizeApply
|
|
return 'config-capability'
|
|
}
|
|
),
|
|
getAvailableToolNames: vi.fn(() => [
|
|
'goodbuddy_config_capabilities',
|
|
'goodbuddy_config_get',
|
|
'goodbuddy_config_plan',
|
|
'goodbuddy_config_apply'
|
|
]),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const goodbuddyConfigService = {
|
|
takePendingReload: vi.fn(() => 'none'),
|
|
revokeRequest: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
const authorized = await authorizeConfigApply?.(
|
|
{
|
|
requestId: request.requestId,
|
|
planId: '11111111-1111-4111-8111-111111111111',
|
|
summary: '删除一个 MCP Server',
|
|
risk: 'high',
|
|
reload: 'after-current-request',
|
|
destructive: true
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
expect(authorized).toBe(expectedAuthorized)
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
toolApproval,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
false,
|
|
goodbuddyConfigService
|
|
)
|
|
harness.getResolvedSettings.mockResolvedValue({
|
|
workspacePath: 'C:\\Workspace'
|
|
})
|
|
harness.approvalBroker.request.mockResolvedValue('once')
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: `conversation-${requestId}`,
|
|
prompt: '删除 MCP',
|
|
workMode: 'execute'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
if (expectedAuthorized) {
|
|
expect(harness.approvalBroker.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
requestId,
|
|
conversationId: `goodbuddy-config:${requestId}`,
|
|
scopeKey:
|
|
'goodbuddy-config:11111111-1111-4111-8111-111111111111',
|
|
title: '允许高风险 GoodBuddy 配置变更?',
|
|
toolName: 'goodbuddy_config_apply',
|
|
allowPermanent: false
|
|
}),
|
|
expect.any(AbortSignal),
|
|
expect.any(Function)
|
|
)
|
|
} else {
|
|
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
|
}
|
|
await harness.dispose()
|
|
}
|
|
})
|
|
|
|
it('coalesces config reload until all active requests finish', async () => {
|
|
const completions = new Map<string, () => void>()
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
await new Promise<void>((resolve) => {
|
|
completions.set(request.requestId, resolve)
|
|
})
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'config-capability'),
|
|
getAvailableToolNames: vi.fn(() => [
|
|
'goodbuddy_config_capabilities'
|
|
]),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const goodbuddyConfigService = {
|
|
takePendingReload: vi.fn(() => 'after-current-request'),
|
|
revokeRequest: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
'always',
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
false,
|
|
goodbuddyConfigService
|
|
)
|
|
harness.getResolvedSettings.mockResolvedValue({
|
|
workspacePath: 'C:\\Workspace'
|
|
})
|
|
const requestIds = [
|
|
'3f496642-f47d-4e0a-8944-a32c77b0d6e3',
|
|
'3f496642-f47d-4e0a-8944-a32c77b0d6e4'
|
|
]
|
|
for (const requestId of requestIds) {
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: `conversation-${requestId}`,
|
|
prompt: '配置 Runtime',
|
|
workMode: 'execute'
|
|
})
|
|
}
|
|
await vi.waitFor(() => expect(completions.size).toBe(2))
|
|
|
|
completions.get(requestIds[0]!)?.()
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestIds[0], 'completed')
|
|
)
|
|
expect(harness.onRuntimeSettingsChanged).not.toHaveBeenCalled()
|
|
|
|
completions.get(requestIds[1]!)?.()
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestIds[1], 'completed')
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
)
|
|
expect(goodbuddyConfigService.takePendingReload).toHaveBeenCalledTimes(
|
|
2
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('preserves bounded runtime errors for persistence and renderer delivery', async () => {
|
|
const fetchCause = Object.assign(
|
|
new Error('connect ECONNREFUSED 127.0.0.1:11434'),
|
|
{
|
|
code: 'ECONNREFUSED',
|
|
syscall: 'connect',
|
|
address: '127.0.0.1',
|
|
port: 11434
|
|
}
|
|
)
|
|
const expectedError = [
|
|
'fetch failed',
|
|
'cause:',
|
|
'connect ECONNREFUSED 127.0.0.1:11434',
|
|
'code: ECONNREFUSED',
|
|
'syscall: connect',
|
|
'address: 127.0.0.1',
|
|
'port: 11434'
|
|
].join('\n')
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: false,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run() {
|
|
yield* []
|
|
throw new TypeError('fetch failed', { cause: fetchCause })
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'ask',
|
|
workMode: 'ask'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
expectedError
|
|
)
|
|
)
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({
|
|
message: expectedError
|
|
})
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers Magic Notes analysis', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
channelMocks.stop.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('persists comments and usage without exposing an analysis task', async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-magic-ipc-'))
|
|
const database = new AssistantDatabase(
|
|
join(directory, 'assistant.sqlite')
|
|
)
|
|
database.initialize('C:\\Workspace')
|
|
const note = database.createMagicNote({
|
|
title: 'API 回归测试'
|
|
})
|
|
const withEntry = database.createMagicNoteEntry({
|
|
noteId: note.id,
|
|
content: {
|
|
version: 1,
|
|
ops: [{ insert: '请完成发布清单。\n' }]
|
|
},
|
|
plainText: '请完成发布清单。'
|
|
})
|
|
const entry = withEntry.entries[0]!
|
|
const releaseConversation = vi.fn(async () => undefined)
|
|
const disposeRuntime = vi.fn(async () => undefined)
|
|
let analysisRequestId = ''
|
|
const analysisRuntime = {
|
|
releaseConversation,
|
|
dispose: disposeRuntime,
|
|
async *run(request: { requestId: string }) {
|
|
analysisRequestId = request.requestId
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'model-usage',
|
|
callId: 'magic-call-1',
|
|
runtime: 'model',
|
|
provider: 'openai',
|
|
model: 'test-model',
|
|
inputTokens: 20,
|
|
outputTokens: 10,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0
|
|
} as const
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta:
|
|
'{"comments":[{"kind":"suggestion","content":"先核对发布材料。"}]}'
|
|
} as const
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
runtimeFactoryMocks.createDefaultModelRuntime.mockReturnValue(
|
|
analysisRuntime
|
|
)
|
|
const webContents = {
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const disposeHandlers = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{
|
|
getResolvedSettings: vi.fn(async () => ({
|
|
workspacePath: 'C:\\Workspace'
|
|
}))
|
|
} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
database,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined)
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
try {
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.magicNotesAnalyze)?.(
|
|
event,
|
|
{
|
|
entryId: entry.id,
|
|
requestId: '00000000-0000-4000-8000-000000000701',
|
|
direction: 'general',
|
|
format: 'structured'
|
|
}
|
|
)
|
|
).resolves.toMatchObject({
|
|
entries: [
|
|
{
|
|
comments: [
|
|
expect.objectContaining({
|
|
content: '先核对发布材料。'
|
|
})
|
|
]
|
|
}
|
|
]
|
|
})
|
|
expect(database.listTasks()).toEqual([])
|
|
expect(database.getTokenUsageSummary().records).toEqual([
|
|
expect.objectContaining({
|
|
requestId: analysisRequestId,
|
|
provider: 'openai',
|
|
model: 'test-model',
|
|
totalTokens: 30
|
|
})
|
|
])
|
|
expect(releaseConversation).toHaveBeenCalledWith(
|
|
`magic-notes:${entry.id}`
|
|
)
|
|
expect(disposeRuntime).toHaveBeenCalledOnce()
|
|
} finally {
|
|
await disposeHandlers()
|
|
database.close()
|
|
await rm(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|