Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
13974 lines
523 KiB
TypeScript
13974 lines
523 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type {} from '@testing-library/jest-dom/vitest'
|
|
import { EventEmitter } from 'node:events'
|
|
import { setImmediate as nextTurn } from 'node:timers/promises'
|
|
import { assistantStorageMethods } from './desktop-storage-contracts'
|
|
import { asyncSupervisionStorage } from '../../tests/support/async-supervision-storage'
|
|
import { TestKnowledgeService } from '../../tests/support/knowledge-test-service'
|
|
import { AgentModelCallLedger } from '../agent-daemon/agent-model-gateway'
|
|
import { createServer } from 'node:http'
|
|
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
realpath,
|
|
rm,
|
|
writeFile
|
|
} from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { delimiter, dirname, join } from 'node:path'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { ipcChannels } from '../shared/ipc-channels'
|
|
import type {
|
|
AssistantProject,
|
|
AssistantSchedule,
|
|
ConversationSnapshot,
|
|
ConversationQueueItem
|
|
} from '../shared/assistant-contracts'
|
|
import {
|
|
browserTabIdSchema,
|
|
defaultModelProfileId,
|
|
defaultRuntimeSettings,
|
|
runtimeSettingsInputSchema,
|
|
type AgentEvent,
|
|
type BrowserLiveState,
|
|
type BrowserTabId,
|
|
type ConversationQueueDispatch
|
|
} from '../shared/contracts'
|
|
import type {
|
|
SshHostAgentConnectionStatus
|
|
} from '../shared/ssh-host-contracts'
|
|
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
|
|
import { AssistantDatabase } from './assistant/assistant-database'
|
|
import { ChannelSettingsStore } from './channels/channel-settings-store'
|
|
import { ChannelService } from './channels/channel-service'
|
|
import { SqliteChannelOutbox } from './channels/sqlite-channel-state'
|
|
import type { ChannelDriver, ChannelInboundHandler } from './channels/channel-driver'
|
|
import { SubagentService, createSubagentRuntime } from './assistant/subagent-service'
|
|
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
|
|
import { DocumentResultStorage } from './document-result-storage'
|
|
import { ImageGenerationService } from './agent/image-generation-service'
|
|
import { ApplicationSettingsStore } from './application-settings-store'
|
|
import * as desktopNotification from './desktop-notification'
|
|
import type { AgentExecutionRequest } from './agent/runtime'
|
|
import type { SelectedRuntimeResolver } from './agent/selected-runtime-manager'
|
|
import { ExecutionSpaceResolver, type ExecutionSpaceDescriptor } from './execution-space'
|
|
import type { WorkspaceAccess } from './workspace'
|
|
import { KnowledgeDatabase } from './knowledge/knowledge-database'
|
|
import { BrowserNavigationStoppedError } from './browser/browser-service'
|
|
import { RemotePromptCancelledError } from './agent/acp-remote-runtime'
|
|
import { CapabilityService } from './capabilities/capability-service'
|
|
import { ObsidianService } from './obsidian'
|
|
import { ReadonlyQueryReader } from './readonly-query-reader'
|
|
import { packageObsidianMcpVault } from './obsidian/package-mcpvault'
|
|
import {
|
|
registerIpcHandlers as registerProductionIpcHandlers,
|
|
sendRemoteEnvironmentUpdateProgress,
|
|
sendRemoteProjectSaveProgress
|
|
} from './ipc'
|
|
|
|
// Most IPC tests use partial databases. Real database fixtures retain their timing implementation.
|
|
const beforeStorageCall = new WeakMap<object, (method: string) => Promise<void>>()
|
|
const storageReaders = new WeakMap<object, ReadonlyQueryReader>()
|
|
function registerIpcHandlers(...args: Parameters<typeof registerProductionIpcHandlers>): ReturnType<typeof registerProductionIpcHandlers> {
|
|
const database = args[7]
|
|
database.startExecutionTiming ??= vi.fn()
|
|
database.endExecutionTiming ??= vi.fn()
|
|
args[45] ??= asyncSupervisionStorage(database as AssistantDatabase, async () => { if (!vi.isFakeTimers()) await nextTurn(); else await Promise.resolve() })
|
|
args[7] = Object.fromEntries(assistantStorageMethods.map(method => [method, async (...input: unknown[]) => {
|
|
if (!vi.isFakeTimers()) await nextTurn(); else await Promise.resolve()
|
|
await beforeStorageCall.get(database)?.(method)
|
|
const target = database as unknown as Record<string, (...values: unknown[]) => unknown>
|
|
const reader = storageReaders.get(database)
|
|
const reads: Record<string, string> = { listSupervisionResults: 'supervisionOverview', getSupervisionGraph: 'supervisionGraph', getSupervisionStories: 'supervisionStories' }
|
|
if (reader && reads[method]) return reader.call(reads[method]!, input)
|
|
return await target[method]!.apply(database, input)
|
|
}])) as typeof database
|
|
const removeStatsListener = (database as Partial<AssistantDatabase>).onExecutionStatsChanged?.(() => {
|
|
args[0].webContents.send(ipcChannels.tasksExecutionStatsChanged)
|
|
})
|
|
const dispose = registerProductionIpcHandlers(...args)
|
|
return async () => { try { await dispose() } finally { removeStatsListener?.() } }
|
|
}
|
|
|
|
describe('embedding IPC boundary', () => {
|
|
it('validates explicit IDs and keeps ZIP paths in the native dialog', async () => {
|
|
const source = await readFile(
|
|
join(process.cwd(), 'src', 'main', 'ipc.ts'),
|
|
'utf8'
|
|
)
|
|
const region =
|
|
source.match(
|
|
/const requireEmbeddingProvider[\s\S]*?registerHandler\(ipcChannels\.speechModelsGet/u
|
|
)?.[0] ?? ''
|
|
expect(region).toContain('embeddingConnectionIdRequestSchema.parse(input)')
|
|
expect(region).toContain('embeddingModelInstallInputSchema.parse(input)')
|
|
expect(region).toContain('embeddingModelActionInputSchema.parse(input)')
|
|
expect(region).toContain(
|
|
'embeddingModelProgressSnapshotSchema.parse'
|
|
)
|
|
expect(region).toContain(
|
|
'embeddingModelManager.getProgressSnapshot()'
|
|
)
|
|
expect(region).toContain("properties: ['openFile']")
|
|
expect(region).toContain('filters: modelArchiveDialogFilters')
|
|
expect(region).not.toMatch(
|
|
/embeddingModelsImportArchive[\s\S]{0,300}input\.(?:path|archivePath|directory)/u
|
|
)
|
|
expect(region).not.toMatch(
|
|
/(?:agent|selectedRuntimes|remoteModelGateway).{0,80}(?:embedding|vector)/iu
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('local tool environment IPC boundary', () => {
|
|
it('trust-checks every operation and accepts only shared bounded schemas', async () => {
|
|
const region = await readFile(
|
|
join(process.cwd(), 'src', 'main', 'local-tool-environment', 'local-tool-environment-ipc.ts'),
|
|
'utf8'
|
|
)
|
|
expect(region.match(/assertTrustedSender\(event, window\)/gu)).toHaveLength(8)
|
|
expect(region).toContain('localToolEnvironmentSettingsSchema.parse(input)')
|
|
expect(region).toContain('localToolKindInputSchema.parse(input)')
|
|
expect(region).toContain('localToolDiagnoseInputSchema.parse(input)')
|
|
expect(region.match(/z\.undefined\(\)\.parse\(input\)/gu)).toHaveLength(5)
|
|
expect(region).not.toMatch(
|
|
/\b(?:command|args|env|url|hash|installDirectory|shimContents)\b/u
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('terminal IPC boundary', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates terminal requests and routes them through the trusted renderer owner', async () => {
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
id: 73,
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const snapshot = {
|
|
sessionId: '00000000-0000-4000-8000-000000000073',
|
|
target: { type: 'local' as const },
|
|
title: 'PowerShell',
|
|
state: 'running' as const,
|
|
cwd: 'C:\\Workspace',
|
|
cols: 120,
|
|
rows: 36,
|
|
lastSequence: 0
|
|
}
|
|
const terminalSessionManager = {
|
|
create: vi.fn(async () => snapshot),
|
|
enableEventDelivery: vi.fn(),
|
|
write: vi.fn(),
|
|
resize: vi.fn(),
|
|
snapshot: vi.fn(() => snapshot),
|
|
acknowledge: vi.fn(),
|
|
close: vi.fn(async () => ({
|
|
...snapshot,
|
|
state: 'closed' as const
|
|
})),
|
|
closeOwner: vi.fn(async () => undefined)
|
|
}
|
|
const args: Parameters<typeof registerIpcHandlers> = [
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => {}),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
terminalSessionManager as never
|
|
]
|
|
const dispose = registerIpcHandlers(...args)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.terminalCreate)?.(event, {
|
|
target: { type: 'local' },
|
|
cols: 120,
|
|
rows: 36
|
|
})
|
|
).resolves.toEqual(snapshot)
|
|
expect(terminalSessionManager.create).toHaveBeenCalledWith(73, {
|
|
target: { type: 'local' },
|
|
cols: 120,
|
|
rows: 36
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
terminalSessionManager.enableEventDelivery
|
|
).toHaveBeenCalledWith(73, snapshot.sessionId)
|
|
)
|
|
|
|
electronMocks.handlers.get(ipcChannels.terminalWrite)?.(event, {
|
|
sessionId: snapshot.sessionId,
|
|
data: 'echo ready\r'
|
|
})
|
|
electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, {
|
|
sessionId: snapshot.sessionId,
|
|
cols: 100,
|
|
rows: 28
|
|
})
|
|
electronMocks.handlers.get(ipcChannels.terminalAck)?.(event, {
|
|
sessionId: snapshot.sessionId,
|
|
sequence: 4
|
|
})
|
|
expect(
|
|
await electronMocks.handlers.get(ipcChannels.terminalSnapshot)?.(
|
|
event,
|
|
{ sessionId: snapshot.sessionId }
|
|
)
|
|
).toEqual(snapshot)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.terminalClose)?.(event, {
|
|
sessionId: snapshot.sessionId
|
|
})
|
|
).resolves.toMatchObject({ state: 'closed' })
|
|
|
|
expect(terminalSessionManager.write).toHaveBeenCalledWith(
|
|
73,
|
|
snapshot.sessionId,
|
|
'echo ready\r'
|
|
)
|
|
expect(terminalSessionManager.resize).toHaveBeenCalledWith(
|
|
73,
|
|
snapshot.sessionId,
|
|
{ cols: 100, rows: 28 }
|
|
)
|
|
expect(terminalSessionManager.acknowledge).toHaveBeenCalledWith(
|
|
73,
|
|
snapshot.sessionId,
|
|
4
|
|
)
|
|
expect(terminalSessionManager.snapshot).toHaveBeenCalledWith(
|
|
73,
|
|
snapshot.sessionId
|
|
)
|
|
expect(terminalSessionManager.close).toHaveBeenCalledWith(
|
|
73,
|
|
snapshot.sessionId
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.(event)
|
|
).resolves.toBe('Get-Location\r')
|
|
expect(electronMocks.readClipboardText).toHaveBeenCalledOnce()
|
|
|
|
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
|
|
event,
|
|
'## Copied response'
|
|
)
|
|
expect(electronMocks.writeClipboardText).toHaveBeenCalledWith(
|
|
'## Copied response'
|
|
)
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.({
|
|
sender: { ...webContents, id: 99 },
|
|
senderFrame: webContents.mainFrame
|
|
}))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
|
|
event,
|
|
{ text: 'invalid' }
|
|
))).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
|
|
{
|
|
sender: { ...webContents, id: 99 },
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
'untrusted'
|
|
))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(ipcChannels.terminalWrite)?.(
|
|
{
|
|
sender: { ...webContents, id: 99 },
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
{
|
|
sessionId: snapshot.sessionId,
|
|
data: 'untrusted'
|
|
}
|
|
))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, {
|
|
sessionId: snapshot.sessionId,
|
|
cols: 0,
|
|
rows: 28
|
|
}))).rejects.toThrow()
|
|
|
|
await dispose()
|
|
expect(terminalSessionManager.closeOwner).toHaveBeenCalledWith(73)
|
|
})
|
|
})
|
|
|
|
type InvokeHandler = (event: unknown, input?: unknown) => unknown
|
|
type KnowledgeGrantMock = (
|
|
requestId: string,
|
|
libraryIds: readonly string[],
|
|
signal: AbortSignal,
|
|
access: 'read' | 'write'
|
|
) => string
|
|
|
|
const electronMocks = vi.hoisted(() => {
|
|
const handlers = new Map<string, InvokeHandler>()
|
|
return {
|
|
handlers,
|
|
invoke: vi.fn(),
|
|
fetch: vi.fn<typeof fetch>(),
|
|
handle: vi.fn((channel: string, handler: InvokeHandler) => {
|
|
handlers.set(channel, handler)
|
|
}),
|
|
removeHandler: vi.fn((channel: string) => {
|
|
handlers.delete(channel)
|
|
}),
|
|
showOpenDialog: vi.fn(async () => ({
|
|
canceled: true,
|
|
filePaths: [] as string[]
|
|
})),
|
|
showSaveDialog: vi.fn(async () => ({
|
|
canceled: true,
|
|
filePath: undefined as string | undefined
|
|
})),
|
|
openPath: vi.fn(async () => ''),
|
|
showItemInFolder: vi.fn(),
|
|
openExternal: vi.fn(async () => undefined),
|
|
readClipboardText: vi.fn(async () => 'Get-Location\r'),
|
|
writeClipboardText: vi.fn(async () => {})
|
|
}
|
|
})
|
|
|
|
const channelMocks = vi.hoisted(() => ({
|
|
executor: undefined as
|
|
| ((
|
|
message: {
|
|
channel: string
|
|
eventId: string
|
|
senderId: string
|
|
conversationId: string
|
|
conversationType: 'direct' | 'group'
|
|
text: string
|
|
mentioned: boolean
|
|
attachments?: Array<{
|
|
name: string
|
|
mimeType: string
|
|
size: number
|
|
kind: 'image' | 'file'
|
|
dataBase64: string
|
|
}>
|
|
attachmentError?: string
|
|
},
|
|
signal: AbortSignal,
|
|
reportProgress?: (result: {
|
|
status: string
|
|
output?: string
|
|
error?: string
|
|
}) => Promise<void>
|
|
) => Promise<{
|
|
status: string
|
|
output?: string
|
|
error?: string
|
|
attachments?: Array<{
|
|
name: string
|
|
mimeType: string
|
|
size: number
|
|
kind: 'image' | 'file'
|
|
dataBase64: string
|
|
}>
|
|
}>)
|
|
| undefined,
|
|
stop: vi.fn(async () => undefined)
|
|
}))
|
|
|
|
const runtimeFactoryMocks = vi.hoisted(() => ({
|
|
createModelProfileRuntime: vi.fn(),
|
|
createDefaultModelRuntime: vi.fn()
|
|
}))
|
|
|
|
describe('registerIpcHandlers computer capabilities', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
channelMocks.stop.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('validates computer capability requests and restricts them to the trusted renderer', async () => {
|
|
const webContents = Object.assign(new EventEmitter(), {
|
|
id: 7,
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
})
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const snapshot = {
|
|
skills: [],
|
|
mcpServers: [],
|
|
computerCapabilities: [],
|
|
browserProfiles: { profiles: [], defaultProfileId: null }
|
|
}
|
|
const capabilityService = {
|
|
importSkill: vi.fn(async () => snapshot),
|
|
setBuiltinMcpServerEnabled: vi.fn(async () => snapshot),
|
|
setBuiltinMcpServerAssignments: vi.fn(async () => snapshot),
|
|
setComputerCapabilityEnabled: vi.fn(async () => snapshot),
|
|
getComputerCapabilityStatus: vi.fn(async () => ({
|
|
enabled: true,
|
|
supported: true
|
|
})),
|
|
setWebSearchEnabled: vi.fn(async () => snapshot),
|
|
createBrowserProfile: vi.fn(async () => snapshot),
|
|
diagnoseComputerCapability: vi.fn(async () => ({
|
|
capabilityId: 'host-browser-control',
|
|
status: 'disabled',
|
|
checkedAt: '2026-08-05T00:00:00.000Z',
|
|
checks: []
|
|
}))
|
|
}
|
|
const onRuntimeSettingsChanged = vi.fn(async () => {})
|
|
const navigate = vi.fn(async () => ({
|
|
url: 'https://example.com/',
|
|
origin: 'https://example.com'
|
|
}))
|
|
const back = vi.fn(async () => ({
|
|
url: 'https://example.com/previous',
|
|
origin: 'https://example.com'
|
|
}))
|
|
const reload = vi.fn(async () => ({
|
|
url: 'https://example.com/',
|
|
origin: 'https://example.com'
|
|
}))
|
|
const stopLoading = vi.fn(async () => true)
|
|
const setViewport = vi.fn()
|
|
const releaseConversation = vi.fn(async () => {})
|
|
const createTab = vi.fn(async (conversationId: string) => ({
|
|
conversationId,
|
|
tabId: '4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
|
|
}))
|
|
const listTabs = vi.fn(() => [])
|
|
const getVisibleTabId = vi.fn(() => undefined)
|
|
const acquireTabUsage = vi.fn(
|
|
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
|
|
conversationId,
|
|
tabId,
|
|
owner,
|
|
signal: new AbortController().signal,
|
|
release: vi.fn()
|
|
})
|
|
)
|
|
const closeTab = vi.fn(async () => {})
|
|
const snapshotBrowser = vi.fn(async () => ({
|
|
url: 'https://example.com/',
|
|
title: 'Example',
|
|
nodes: [],
|
|
truncated: false
|
|
}))
|
|
const clickBrowser = vi.fn(async () => {})
|
|
const typeBrowser = vi.fn(async () => {})
|
|
const selectBrowser = vi.fn(async () => {})
|
|
const screenshotBrowser = vi.fn(async () => ({
|
|
type: 'image' as const,
|
|
mimeType: 'image/jpeg' as const,
|
|
data: '/9j/2Q=='
|
|
}))
|
|
const selectFiles = vi.fn(
|
|
async (
|
|
_window: unknown,
|
|
onProgress: (progress: {
|
|
phase: 'parsing'
|
|
fileName: string
|
|
fileNumber: number
|
|
fileCount: number
|
|
}) => void
|
|
) => {
|
|
onProgress({
|
|
phase: 'parsing',
|
|
fileName: 'scan.pdf',
|
|
fileNumber: 1,
|
|
fileCount: 1
|
|
})
|
|
return []
|
|
}
|
|
)
|
|
let browserStateListener:
|
|
| ((state: BrowserLiveState) => void)
|
|
| undefined
|
|
const shortcutSnapshot: import('../shared/shortcut').GlobalShortcutSettingsSnapshot = {
|
|
platform: 'win32',
|
|
settings: {
|
|
enabled: true,
|
|
accelerator: 'Control+Alt+K'
|
|
},
|
|
defaultSettings: {
|
|
enabled: true,
|
|
accelerator: 'CommandOrControl+Shift+Space'
|
|
},
|
|
displayAccelerator: 'Ctrl + Alt + K',
|
|
registered: true,
|
|
registeredAccelerator: 'Control+Alt+K',
|
|
status: 'registered' as const
|
|
}
|
|
const shortcutSettingsService = {
|
|
getSnapshot: vi.fn(() => shortcutSnapshot),
|
|
update: vi.fn(async () => ({
|
|
ok: true as const,
|
|
snapshot: shortcutSnapshot
|
|
}))
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
capabilityService as never,
|
|
{ clear: vi.fn(), selectFiles, importFiles: selectFiles } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged,
|
|
undefined,
|
|
{
|
|
createTab,
|
|
listTabs,
|
|
getVisibleTabId,
|
|
acquireTabUsage,
|
|
closeTab,
|
|
navigate,
|
|
back,
|
|
reload,
|
|
snapshot: snapshotBrowser,
|
|
click: clickBrowser,
|
|
type: typeBrowser,
|
|
select: selectBrowser,
|
|
screenshot: screenshotBrowser,
|
|
stopLoading,
|
|
setViewport,
|
|
releaseConversation,
|
|
onState: (listener) => {
|
|
browserStateListener = listener
|
|
return vi.fn()
|
|
}
|
|
},
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
shortcutSettingsService as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
expect(
|
|
await electronMocks.handlers.get(ipcChannels.appInfo)?.(event)
|
|
).toMatchObject({
|
|
shortcut: 'Ctrl + Alt + K',
|
|
shortcutStatus: 'registered'
|
|
})
|
|
for (const status of ['disabled', 'conflict', 'failed'] as const) {
|
|
shortcutSettingsService.getSnapshot.mockReturnValueOnce({
|
|
...shortcutSnapshot,
|
|
registered: false,
|
|
registeredAccelerator: undefined,
|
|
status
|
|
})
|
|
expect(await electronMocks.handlers.get(ipcChannels.appInfo)?.(event)).toMatchObject({
|
|
shortcut: '',
|
|
shortcutStatus: status
|
|
})
|
|
}
|
|
expect(
|
|
await electronMocks.handlers.get(ipcChannels.shortcutSettingsGet)?.(
|
|
event
|
|
)
|
|
).toEqual(shortcutSnapshot)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.shortcutSettingsUpdate)?.(
|
|
event,
|
|
{ enabled: true, accelerator: 'ctrl+alt+k' }
|
|
)
|
|
).resolves.toEqual({
|
|
ok: true,
|
|
snapshot: shortcutSnapshot
|
|
})
|
|
expect(shortcutSettingsService.update).toHaveBeenCalledWith({
|
|
enabled: true,
|
|
accelerator: 'Control+Alt+K'
|
|
})
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.contextSelectFiles)?.(event)
|
|
).resolves.toEqual([])
|
|
expect(selectFiles).toHaveBeenCalledWith(window, expect.any(Function))
|
|
const importFiles = electronMocks.handlers.get(ipcChannels.contextImportFiles)!
|
|
await expect(importFiles(event, { paths: ['C:\\scan.pdf'] })).resolves.toEqual([])
|
|
expect(selectFiles).toHaveBeenCalledWith(['C:\\scan.pdf'], expect.any(Function))
|
|
await expect(Promise.resolve().then(() => importFiles(event, { paths: [''] }))).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() => importFiles({ ...event, senderFrame: {} }, { paths: ['C:\\scan.pdf'] }))).rejects.toThrow()
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.contextFileSelectionProgress,
|
|
{
|
|
phase: 'parsing',
|
|
fileName: 'scan.pdf',
|
|
fileNumber: 1,
|
|
fileCount: 1
|
|
}
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesToggleComputer
|
|
)?.(event, {
|
|
capabilityId: 'host-browser-control',
|
|
enabled: true
|
|
})
|
|
).resolves.toEqual(snapshot)
|
|
expect(
|
|
capabilityService.setComputerCapabilityEnabled
|
|
).toHaveBeenCalledWith('host-browser-control', true)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesToggleWebSearch
|
|
)?.(event, false)
|
|
).resolves.toEqual(snapshot)
|
|
expect(capabilityService.setWebSearchEnabled).toHaveBeenCalledWith(false)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(2)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesToggleBuiltinMcp
|
|
)?.(event, {
|
|
serverId: 'knowledge-base',
|
|
enabled: false
|
|
})
|
|
).resolves.toEqual(snapshot)
|
|
expect(
|
|
capabilityService.setBuiltinMcpServerEnabled
|
|
).toHaveBeenCalledWith('knowledge-base', false)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesAssignBuiltinMcp
|
|
)?.(event, {
|
|
serverId: 'knowledge-base',
|
|
assignments: ['model', 'continue']
|
|
})
|
|
).resolves.toEqual(snapshot)
|
|
expect(
|
|
capabilityService.setBuiltinMcpServerAssignments
|
|
).toHaveBeenCalledWith('knowledge-base', ['model', 'continue'])
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(4)
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: ['C:\\meeting-helper.zip']
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesImportSkill
|
|
)?.(event, 'zip')
|
|
).resolves.toEqual(snapshot)
|
|
expect(electronMocks.showOpenDialog).toHaveBeenCalledWith(
|
|
window,
|
|
expect.objectContaining({
|
|
properties: ['openFile'],
|
|
filters: [{ name: 'Skill ZIP', extensions: ['zip'] }]
|
|
})
|
|
)
|
|
expect(capabilityService.importSkill).toHaveBeenCalledWith(
|
|
'C:\\meeting-helper.zip'
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesImportSkill
|
|
)?.(event, 'unsupported')
|
|
).rejects.toThrow()
|
|
|
|
const browserTabId = browserTabIdSchema.parse(
|
|
'4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
|
|
)
|
|
const siblingBrowserTabId = browserTabIdSchema.parse(
|
|
'0387bd61-3a12-40ce-98d7-ef5d14cc8251'
|
|
)
|
|
browserStateListener?.({
|
|
conversationId: 'browser-conversation',
|
|
tabId: browserTabId,
|
|
ownerWindowId: 7,
|
|
status: 'ready',
|
|
sessionActive: true,
|
|
isLoading: false,
|
|
canGoBack: false,
|
|
workbarInstanceId: browserTabId,
|
|
updatedAt: 1
|
|
})
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.browserState,
|
|
expect.objectContaining({
|
|
conversationId: 'browser-conversation',
|
|
status: 'ready'
|
|
})
|
|
)
|
|
const ownedStateSendCount = webContents.send.mock.calls.length
|
|
browserStateListener?.({
|
|
conversationId: 'foreign-conversation',
|
|
tabId: browserTabId,
|
|
ownerWindowId: 8,
|
|
status: 'ready',
|
|
sessionActive: true,
|
|
isLoading: false,
|
|
canGoBack: false,
|
|
updatedAt: 1
|
|
})
|
|
expect(webContents.send).toHaveBeenCalledTimes(ownedStateSendCount)
|
|
browserStateListener?.({
|
|
conversationId: 'browser-conversation',
|
|
tabId: browserTabId,
|
|
ownerWindowId: 7,
|
|
status: 'acting',
|
|
sessionActive: true,
|
|
isLoading: false,
|
|
canGoBack: false,
|
|
workbarInstanceId: browserTabId,
|
|
updatedAt: 2
|
|
})
|
|
const repeatedFramePayload = webContents.send.mock.calls.at(-1)?.[1]
|
|
expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl')
|
|
expect(repeatedFramePayload).toHaveProperty('workbarInstanceId', browserTabId)
|
|
browserStateListener?.({
|
|
conversationId: 'browser-conversation',
|
|
tabId: siblingBrowserTabId,
|
|
ownerWindowId: 7,
|
|
status: 'ready',
|
|
sessionActive: true,
|
|
isLoading: false,
|
|
canGoBack: false,
|
|
workbarInstanceId: siblingBrowserTabId,
|
|
updatedAt: 2
|
|
})
|
|
expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty(
|
|
'workbarInstanceId', siblingBrowserTabId
|
|
)
|
|
browserStateListener?.({
|
|
conversationId: 'browser-conversation',
|
|
tabId: browserTabId,
|
|
ownerWindowId: 7,
|
|
status: 'stopped',
|
|
sessionActive: false,
|
|
isLoading: false,
|
|
canGoBack: false,
|
|
updatedAt: 3
|
|
})
|
|
browserStateListener?.({
|
|
conversationId: 'browser-conversation',
|
|
tabId: browserTabId,
|
|
ownerWindowId: 7,
|
|
status: 'ready',
|
|
sessionActive: true,
|
|
isLoading: false,
|
|
canGoBack: false,
|
|
workbarInstanceId: browserTabId,
|
|
updatedAt: 4
|
|
})
|
|
expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty(
|
|
'workbarInstanceId',
|
|
browserTabId
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserStop)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(releaseConversation).toHaveBeenCalledWith(
|
|
'browser-conversation',
|
|
7
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserCreateTab)?.(event, {
|
|
conversationId: 'browser-conversation',
|
|
workbarInstanceId: siblingBrowserTabId
|
|
})
|
|
).resolves.toMatchObject({ tabId: browserTabId })
|
|
expect(createTab).toHaveBeenCalledWith(
|
|
'browser-conversation',
|
|
7,
|
|
expect.any(AbortSignal),
|
|
siblingBrowserTabId
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserListTabs)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).resolves.toEqual([])
|
|
expect(listTabs).toHaveBeenCalledWith('browser-conversation', 7)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserCloseTab)?.(event, {
|
|
conversationId: 'browser-conversation',
|
|
tabId: browserTabId
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(closeTab).toHaveBeenCalledWith(
|
|
'browser-conversation',
|
|
browserTabId,
|
|
7
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
|
|
conversationId: 'browser-conversation',
|
|
url: 'https://example.com/'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(navigate).toHaveBeenCalledWith(
|
|
'browser-conversation',
|
|
'https://example.com/',
|
|
expect.any(AbortSignal),
|
|
undefined,
|
|
7
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserBack)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(back).toHaveBeenCalledWith(
|
|
'browser-conversation',
|
|
expect.any(AbortSignal),
|
|
undefined,
|
|
7
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserReload)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(reload).toHaveBeenCalledWith(
|
|
'browser-conversation',
|
|
expect.any(AbortSignal),
|
|
undefined,
|
|
7
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
|
|
event,
|
|
{ conversationId: 'browser-conversation' }
|
|
)
|
|
).resolves.toBeUndefined()
|
|
expect(stopLoading).toHaveBeenCalledWith(
|
|
'browser-conversation',
|
|
undefined,
|
|
7
|
|
)
|
|
expect(
|
|
await electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
|
|
conversationId: 'browser-conversation',
|
|
leaseToken: siblingBrowserTabId,
|
|
bounds: { x: 900, y: 120, width: 320, height: 600 }
|
|
})
|
|
).toBeUndefined()
|
|
expect(setViewport).toHaveBeenCalledWith(
|
|
'browser-conversation',
|
|
{ x: 900, y: 120, width: 320, height: 600 },
|
|
undefined,
|
|
siblingBrowserTabId,
|
|
7
|
|
)
|
|
expect(
|
|
await electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {
|
|
leaseToken: siblingBrowserTabId
|
|
})
|
|
).toBeUndefined()
|
|
expect(setViewport).toHaveBeenLastCalledWith(
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
siblingBrowserTabId,
|
|
7
|
|
)
|
|
navigate.mockImplementationOnce(
|
|
async (...args: unknown[]) =>
|
|
new Promise<never>((_resolve, reject) => {
|
|
const signal = args[2] as AbortSignal
|
|
signal.addEventListener('abort', () => reject(signal.reason), {
|
|
once: true
|
|
})
|
|
})
|
|
)
|
|
const abandonedNavigation = electronMocks.handlers.get(
|
|
ipcChannels.browserNavigate
|
|
)?.(event, {
|
|
conversationId: 'browser-conversation',
|
|
url: 'https://example.com/abandoned'
|
|
}) as Promise<void>
|
|
await vi.waitFor(() => expect(webContents.listenerCount('destroyed')).toBe(1))
|
|
webContents.emit('destroyed')
|
|
await expect(abandonedNavigation).rejects.toThrow('所属窗口已关闭')
|
|
navigate.mockRejectedValueOnce(new BrowserNavigationStoppedError())
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
|
|
conversationId: 'browser-conversation',
|
|
url: 'https://example.com/slow'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
capabilityService.getComputerCapabilityStatus.mockResolvedValueOnce({
|
|
enabled: false,
|
|
supported: true
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserBack)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(back).toHaveBeenCalledTimes(2)
|
|
capabilityService.getComputerCapabilityStatus.mockResolvedValueOnce({
|
|
enabled: true,
|
|
supported: false
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserReload)?.(event, {
|
|
conversationId: 'browser-conversation'
|
|
})
|
|
).rejects.toThrow('不支持')
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, {
|
|
conversationId: 'browser-conversation',
|
|
url: 'https://example.com/',
|
|
debugger: true
|
|
})
|
|
).rejects.toThrow()
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
|
|
{
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
{ conversationId: 'browser-conversation' }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.browserStopLoading)?.(
|
|
event,
|
|
{
|
|
conversationId: 'browser-conversation',
|
|
rawElectron: true
|
|
}
|
|
)
|
|
).rejects.toThrow()
|
|
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesCreateBrowserProfile
|
|
)?.(event, {
|
|
name: '工作配置',
|
|
executable: 'C:\\unsafe.exe'
|
|
}))).rejects.toThrow()
|
|
expect(capabilityService.createBrowserProfile).not.toHaveBeenCalled()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesCreateBrowserProfile
|
|
)?.(event, {
|
|
name: '工作配置'
|
|
})
|
|
).resolves.toEqual(snapshot)
|
|
expect(capabilityService.createBrowserProfile).toHaveBeenCalledWith(
|
|
'工作配置'
|
|
)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(5)
|
|
|
|
const capabilityChannels = [
|
|
ipcChannels.capabilitiesSnapshot,
|
|
ipcChannels.runtimeExtensionsSnapshot,
|
|
ipcChannels.runtimeExtensionsApply,
|
|
ipcChannels.capabilitiesImportSkill,
|
|
ipcChannels.capabilitiesRemoveSkill,
|
|
ipcChannels.capabilitiesToggleSkill,
|
|
ipcChannels.capabilitiesAssignSkill,
|
|
ipcChannels.capabilitiesUpdateObsidianSettings,
|
|
ipcChannels.capabilitiesTestObsidianConnection,
|
|
ipcChannels.capabilitiesSelectObsidianVault,
|
|
ipcChannels.capabilitiesToggleBuiltinMcp,
|
|
ipcChannels.capabilitiesAssignBuiltinMcp,
|
|
ipcChannels.capabilitiesSaveMcp,
|
|
ipcChannels.capabilitiesRemoveMcp,
|
|
ipcChannels.capabilitiesTestMcp,
|
|
ipcChannels.capabilitiesToggleWebSearch,
|
|
ipcChannels.capabilitiesTestWebSearch,
|
|
ipcChannels.capabilitiesToggleComputer,
|
|
ipcChannels.capabilitiesConfigureComputer,
|
|
ipcChannels.capabilitiesDiagnoseComputer,
|
|
ipcChannels.capabilitiesCreateBrowserProfile,
|
|
ipcChannels.capabilitiesRenameBrowserProfile,
|
|
ipcChannels.capabilitiesDefaultBrowserProfile,
|
|
ipcChannels.capabilitiesRemoveBrowserProfile
|
|
]
|
|
const registrations = electronMocks.handle.mock.calls.map(([channel]) => channel)
|
|
const start = registrations.indexOf(ipcChannels.capabilitiesSnapshot)
|
|
expect(registrations.slice(start - 1, start + capabilityChannels.length + 1)).toEqual([
|
|
ipcChannels.expertsRemove,
|
|
...capabilityChannels,
|
|
ipcChannels.contextSelectFiles
|
|
])
|
|
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.capabilitiesDiagnoseComputer
|
|
)?.(
|
|
{
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
'host-browser-control'
|
|
))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await dispose()
|
|
for (const channel of capabilityChannels) {
|
|
expect(electronMocks.removeHandler).toHaveBeenCalledWith(channel)
|
|
expect(await electronMocks.handlers.has(channel)).toBe(false)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers update source routing', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('uses the persisted source for checks and the download page', async () => {
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const result = {
|
|
updateAvailable: true,
|
|
currentVersion: '1.0.0',
|
|
latestVersion: '1.1.0',
|
|
releaseUrl: 'https://mesalogo.github.io/goodbuddy/#download',
|
|
target: {
|
|
platform: 'windows',
|
|
arch: 'x64',
|
|
formats: ['nsis', 'portable'],
|
|
files: []
|
|
}
|
|
}
|
|
const getApplicationSettings = vi
|
|
.fn()
|
|
.mockResolvedValue({ updateSource: 'mirror' })
|
|
const versionChecker = {
|
|
check: vi.fn(async () => result)
|
|
}
|
|
const unsubscribe = vi.fn()
|
|
const onChanged = vi.fn<(listener: (settings: unknown) => void) => () => void>(() => unsubscribe)
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{ get: getApplicationSettings, onChanged } as never,
|
|
versionChecker as never
|
|
)
|
|
|
|
const changedSettings = { updateSource: 'github' }
|
|
onChanged.mock.calls[0]
|
|
expect(webContents.send).toHaveBeenCalledWith(ipcChannels.applicationSettingsChanged, changedSettings)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.versionCheck)?.(event)
|
|
).resolves.toEqual(result)
|
|
expect(versionChecker.check).toHaveBeenCalledWith('mirror')
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.versionCheckResult,
|
|
result
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.versionOpenReleasePage
|
|
)?.(event)
|
|
).resolves.toBeUndefined()
|
|
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
|
|
'https://mesalogo.github.io/goodbuddy/#download'
|
|
)
|
|
|
|
getApplicationSettings.mockResolvedValueOnce({
|
|
updateSource: 'github'
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.versionOpenReleasePage
|
|
)?.(event)
|
|
).resolves.toBeUndefined()
|
|
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
|
|
'https://github.com/mesalogo/goodbuddy/releases'
|
|
)
|
|
|
|
await dispose()
|
|
expect(unsubscribe).toHaveBeenCalledOnce()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers model download source routing', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('uses the persisted source and rejects stale renderer requests', async () => {
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const speechSnapshot = {
|
|
rootDirectory: 'C:\\models\\speech',
|
|
selectedDownloadSource: 'hugging-face',
|
|
catalog: [],
|
|
installed: [],
|
|
operations: [],
|
|
selectedModelId: null
|
|
}
|
|
const ocrSnapshot = {
|
|
settings: {},
|
|
status: {},
|
|
ocrModels: {
|
|
rootDirectory: 'C:\\models\\ocr',
|
|
selectedDownloadSource: 'hugging-face',
|
|
catalog: [],
|
|
installed: [],
|
|
operations: []
|
|
}
|
|
}
|
|
const speechModelManager = {
|
|
install: vi.fn(async () => undefined),
|
|
getSnapshot: vi.fn(async () => speechSnapshot),
|
|
getRepositoryUrl: vi.fn(
|
|
() => 'https://huggingface.co/example/speech'
|
|
)
|
|
}
|
|
const documentOcrModelManager = {
|
|
install: vi.fn(async () => undefined),
|
|
getProgressSnapshot: vi.fn(() => ({
|
|
operations: []
|
|
})),
|
|
getRepositoryUrl: vi.fn(
|
|
() => 'https://huggingface.co/example/ocr'
|
|
)
|
|
}
|
|
const documentParsingService = {
|
|
snapshot: vi.fn(async () => ocrSnapshot)
|
|
}
|
|
const applicationSettingsStore = {
|
|
onChanged: vi.fn(),
|
|
get: vi.fn(async () => ({
|
|
modelDownloadSource: 'hugging-face'
|
|
}))
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
applicationSettingsStore as never,
|
|
undefined,
|
|
speechModelManager as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
documentParsingService as never,
|
|
documentOcrModelManager as never
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
|
|
event,
|
|
{
|
|
modelId: 'speech-model',
|
|
expectedDownloadSource: 'hugging-face'
|
|
}
|
|
)
|
|
).resolves.toEqual(speechSnapshot)
|
|
expect(speechModelManager.install).toHaveBeenCalledWith(
|
|
'speech-model',
|
|
'hugging-face'
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
|
|
event,
|
|
{
|
|
modelId: 'speech-model',
|
|
expectedDownloadSource: 'modelscope'
|
|
}
|
|
)
|
|
).rejects.toThrow('模型下载源已变化')
|
|
expect(speechModelManager.install).toHaveBeenCalledTimes(1)
|
|
|
|
applicationSettingsStore.get.mockRejectedValueOnce(
|
|
new Error('settings unavailable')
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.speechModelsInstall)?.(
|
|
event,
|
|
{
|
|
modelId: 'speech-model',
|
|
expectedDownloadSource: 'hugging-face'
|
|
}
|
|
)
|
|
).rejects.toThrow('settings unavailable')
|
|
expect(speechModelManager.install).toHaveBeenCalledTimes(1)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentOcrModelsInstall
|
|
)?.(event, {
|
|
modelId: 'ocr-model',
|
|
expectedDownloadSource: 'hugging-face'
|
|
})
|
|
).resolves.toEqual(ocrSnapshot)
|
|
expect(documentOcrModelManager.install).toHaveBeenCalledWith(
|
|
'ocr-model',
|
|
'hugging-face'
|
|
)
|
|
|
|
expect(
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.documentOcrModelsProgress
|
|
)?.(event)
|
|
).toEqual({ operations: [] })
|
|
expect(
|
|
documentOcrModelManager.getProgressSnapshot
|
|
).toHaveBeenCalledOnce()
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers
|
|
.get(ipcChannels.documentOcrModelsProgress)
|
|
?.({
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
}))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.speechModelsOpenRepository
|
|
)?.(event, { modelId: 'speech-model' })
|
|
).resolves.toBeUndefined()
|
|
expect(speechModelManager.getRepositoryUrl).toHaveBeenCalledWith(
|
|
'speech-model',
|
|
'hugging-face'
|
|
)
|
|
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
|
|
'https://huggingface.co/example/speech'
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentOcrModelsOpenRepository
|
|
)?.(event, { modelId: 'ocr-model' })
|
|
).resolves.toBeUndefined()
|
|
expect(
|
|
documentOcrModelManager.getRepositoryUrl
|
|
).toHaveBeenCalledWith('ocr-model', 'hugging-face')
|
|
expect(electronMocks.openExternal).toHaveBeenLastCalledWith(
|
|
'https://huggingface.co/example/ocr'
|
|
)
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
vi.mock('electron', () => ({
|
|
net: { fetch: electronMocks.fetch },
|
|
contextBridge: { exposeInMainWorld: (name: string, value: unknown) => vi.stubGlobal(name, value) },
|
|
ipcRenderer: { invoke: electronMocks.invoke },
|
|
webUtils: {},
|
|
nativeImage: { createFromBuffer: (buffer: Buffer) => ({ isEmpty: () => false, toPNG: () => buffer }) },
|
|
app: {
|
|
getAppPath: vi.fn(() => process.cwd()),
|
|
getName: vi.fn(() => 'GoodBuddy'),
|
|
getVersion: vi.fn(() => '0.1.0')
|
|
},
|
|
BrowserWindow: class {},
|
|
clipboard: {
|
|
readText: electronMocks.readClipboardText,
|
|
writeText: electronMocks.writeClipboardText
|
|
},
|
|
dialog: {
|
|
showOpenDialog: electronMocks.showOpenDialog,
|
|
showSaveDialog: electronMocks.showSaveDialog
|
|
},
|
|
ipcMain: {
|
|
handle: electronMocks.handle,
|
|
removeHandler: electronMocks.removeHandler
|
|
},
|
|
Notification: class {
|
|
static isSupported(): boolean {
|
|
return false
|
|
}
|
|
},
|
|
shell: {
|
|
openPath: electronMocks.openPath,
|
|
showItemInFolder: electronMocks.showItemInFolder,
|
|
openExternal: electronMocks.openExternal
|
|
}
|
|
}))
|
|
|
|
vi.mock('./agent/create-runtime', () => runtimeFactoryMocks)
|
|
|
|
vi.mock('./channels/channel-env', () => ({
|
|
startEnvironmentChannels: vi.fn(
|
|
(options: { executor: typeof channelMocks.executor }) => {
|
|
channelMocks.executor = options.executor
|
|
return [
|
|
{
|
|
start: vi.fn(async () => undefined),
|
|
stop: channelMocks.stop
|
|
}
|
|
]
|
|
}
|
|
)
|
|
}))
|
|
|
|
describe('registerIpcHandlers DSH runtime extensions', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates extension actions, reloads the Runtime, and trusts only the renderer', async () => {
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const snapshot = {
|
|
marketplaceEnabled: true,
|
|
catalog: [],
|
|
installed: []
|
|
}
|
|
const runtimeExtensionStore = {
|
|
getSnapshot: vi.fn(async () => snapshot),
|
|
applyWithResult: vi.fn(async () => ({
|
|
snapshot,
|
|
changed: true
|
|
}))
|
|
}
|
|
const onRuntimeSettingsChanged = vi.fn(async () => undefined)
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
runtimeExtensionStore as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const action = {
|
|
type: 'set-enabled',
|
|
extensionId: 'dsh-plugin-greet',
|
|
enabled: true
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsSnapshot
|
|
)?.(event)
|
|
).resolves.toEqual(snapshot)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsApply
|
|
)?.(event, action)
|
|
).resolves.toEqual(snapshot)
|
|
expect(
|
|
runtimeExtensionStore.applyWithResult
|
|
).toHaveBeenCalledWith(action)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
const marketplaceAction = {
|
|
type: 'set-marketplace-enabled',
|
|
enabled: false
|
|
}
|
|
runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({
|
|
snapshot: {
|
|
...snapshot,
|
|
marketplaceEnabled: false
|
|
},
|
|
changed: true
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsApply
|
|
)?.(event, marketplaceAction)
|
|
).resolves.toEqual({
|
|
...snapshot,
|
|
marketplaceEnabled: false
|
|
})
|
|
expect(
|
|
runtimeExtensionStore.applyWithResult
|
|
).toHaveBeenLastCalledWith(marketplaceAction)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
runtimeExtensionStore.applyWithResult.mockResolvedValueOnce({
|
|
snapshot,
|
|
changed: false
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsApply
|
|
)?.(event, action)
|
|
).resolves.toEqual(snapshot)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsApply
|
|
)?.(event, {
|
|
...action,
|
|
extensionId: 'Invalid Extension'
|
|
})
|
|
).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeExtensionsSnapshot
|
|
)?.({
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
}))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers SSH hosts', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates narrow host requests and restricts them to the trusted renderer', async () => {
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const hostId = '00000000-0000-4000-8000-000000000105'
|
|
const candidateId = '00000000-0000-4000-8000-000000000106'
|
|
const fingerprint = `SHA256:${'A'.repeat(43)}`
|
|
const host = {
|
|
id: hostId,
|
|
name: 'Build host',
|
|
hostname: 'build.example.com',
|
|
port: 22,
|
|
username: 'builder',
|
|
authentication: 'system-agent' as const,
|
|
credentialConfigured: true,
|
|
credentialSource: 'system-agent' as const,
|
|
hostKey: { state: 'unverified' as const, generation: 0 },
|
|
createdAt: '2026-08-01T00:00:00.000Z',
|
|
updatedAt: '2026-08-01T00:00:00.000Z'
|
|
}
|
|
const snapshot = {
|
|
hosts: [host],
|
|
secureStorageAvailable: true
|
|
}
|
|
const sshHostService = {
|
|
getSnapshot: vi.fn(async () => snapshot),
|
|
remove: vi.fn(
|
|
async (
|
|
_hostId: string,
|
|
commitLocalDeletion?: () => unknown
|
|
) => commitLocalDeletion?.()
|
|
),
|
|
inspectDraftHostKey: vi.fn(async () => ({
|
|
candidateId,
|
|
hostId,
|
|
state: 'unverified' as const,
|
|
algorithm: 'ssh-ed25519',
|
|
fingerprintSha256: fingerprint
|
|
})),
|
|
discardCandidate: vi.fn(),
|
|
validateAndSave: vi.fn(async () => ({
|
|
host,
|
|
connection: {
|
|
hostId,
|
|
connected: true as const,
|
|
latencyMs: 12,
|
|
platform: 'linux' as const,
|
|
architecture: 'x64' as const,
|
|
shell: '/bin/bash',
|
|
homeDirectory: '/home/builder',
|
|
detail: 'SSH 已连接,远端系统为 linux/x64'
|
|
}
|
|
}))
|
|
}
|
|
const deleteProjectsReferencingSshHost = vi.fn(
|
|
(): Array<{ id: string; name: string }> => []
|
|
)
|
|
const listSshHostProjectReferences = vi.fn(() => [])
|
|
const remoteProject = {
|
|
id: '00000000-0000-4000-8000-000000000107',
|
|
name: 'Remote project'
|
|
}
|
|
const remoteProjectSaveService = {
|
|
save: vi.fn(async () => remoteProject),
|
|
cancelCurrent: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const directoryListing = {
|
|
path: '/srv',
|
|
homeDirectory: '/home/builder',
|
|
parentPath: '/',
|
|
entries: [
|
|
{
|
|
name: 'project',
|
|
path: '/srv/project'
|
|
}
|
|
],
|
|
truncated: false
|
|
}
|
|
const listDirectories = vi.fn<
|
|
(
|
|
hostId: string,
|
|
path?: string,
|
|
signal?: AbortSignal
|
|
) => Promise<typeof directoryListing>
|
|
>(async () => directoryListing)
|
|
const sshHostDirectoryBrowser = { listDirectories }
|
|
const remoteEnvironment = {
|
|
hostId,
|
|
checkedAt: '2030-01-01T00:00:00.000Z',
|
|
architecture: 'x64',
|
|
agent: {
|
|
state: 'current',
|
|
expected: {
|
|
version: '0.11.1',
|
|
architecture: 'x64'
|
|
},
|
|
installed: {
|
|
version: '0.11.1',
|
|
architecture: 'x64'
|
|
}
|
|
},
|
|
runtimes: [{
|
|
runtimeId: 'opencode',
|
|
provider: 'opencode',
|
|
state: 'current',
|
|
expected: {
|
|
version: '1.18.29',
|
|
architecture: 'x64'
|
|
},
|
|
installed: {
|
|
version: '1.18.29',
|
|
architecture: 'x64'
|
|
}
|
|
}],
|
|
remoteDownload: {
|
|
available: true,
|
|
source: 'mirror',
|
|
packageSize: 1024
|
|
}
|
|
} as const
|
|
const inspectRemoteEnvironment = vi.fn(
|
|
async () => remoteEnvironment
|
|
)
|
|
const sshHostRemoteEnvironmentInspector = {
|
|
inspect: inspectRemoteEnvironment
|
|
}
|
|
const remoteEnvironmentUpdateService = {
|
|
update: vi.fn(
|
|
async (
|
|
owner: typeof webContents,
|
|
request: {
|
|
hostId: string
|
|
method: 'remote-download' | 'goodbuddy-transfer'
|
|
},
|
|
onProgress?: (progress: {
|
|
hostId: string
|
|
method: 'remote-download' | 'goodbuddy-transfer'
|
|
phase: 'verifying'
|
|
}) => void
|
|
) => {
|
|
expect(owner).toBe(webContents)
|
|
onProgress?.({
|
|
hostId: request.hostId,
|
|
method: request.method,
|
|
phase: 'verifying'
|
|
})
|
|
}
|
|
),
|
|
cancel: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const agentPackageInventory = {
|
|
checkedAt: '2030-01-01T00:00:00.000Z',
|
|
catalog: {
|
|
state: 'available',
|
|
checkedAt: '2030-01-01T00:00:00.000Z',
|
|
error: null
|
|
},
|
|
entries: [
|
|
{
|
|
platform: 'linux',
|
|
architecture: 'x64',
|
|
state: 'verified',
|
|
version: '0.11.2',
|
|
latestVersion: '0.11.4',
|
|
updateAvailable: true,
|
|
remoteRuntimeVersion: '1.18.29',
|
|
agentProtocol: { major: 2, minor: 0 }
|
|
},
|
|
{
|
|
platform: 'linux',
|
|
architecture: 'arm64',
|
|
state: 'not-downloaded',
|
|
version: null,
|
|
latestVersion: '0.11.4',
|
|
updateAvailable: false,
|
|
remoteRuntimeVersion: null,
|
|
agentProtocol: null
|
|
}
|
|
]
|
|
} as const
|
|
const agentPackageManager = {
|
|
getAllSnapshot: vi.fn(async () => agentPackageInventory),
|
|
forPlatform: vi.fn(() => agentPackageManager),
|
|
download: vi.fn(
|
|
async (
|
|
architecture: 'x64' | 'arm64',
|
|
onProgress?: (progress: {
|
|
architecture: 'x64' | 'arm64'
|
|
phase: 'downloading'
|
|
completedBytes: number
|
|
totalBytes: number
|
|
}) => void
|
|
) => {
|
|
onProgress?.({
|
|
architecture,
|
|
phase: 'downloading',
|
|
completedBytes: 1,
|
|
totalBytes: 2
|
|
})
|
|
return agentPackageInventory
|
|
}
|
|
),
|
|
importArchive: vi.fn(async () => agentPackageInventory),
|
|
getExportArchiveName: vi.fn(
|
|
async () =>
|
|
'goodbuddy-agent-0.11.2-linux-arm64.gbagent'
|
|
),
|
|
exportArchive: vi.fn(async () => undefined)
|
|
}
|
|
const selectedRuntimes = {
|
|
reset: vi.fn(async () => undefined)
|
|
}
|
|
let publishAgentConnectionStatus:
|
|
| ((status: SshHostAgentConnectionStatus) => void)
|
|
| undefined
|
|
const removeAgentConnectionStatusListener = vi.fn()
|
|
const remoteAgentConnectionManager = {
|
|
getHostConnectionState: vi.fn(() => 'ready' as const),
|
|
onHostConnectionStateChange: vi.fn(
|
|
(
|
|
listener: (status: SshHostAgentConnectionStatus) => void
|
|
) => {
|
|
publishAgentConnectionStatus = listener
|
|
return removeAgentConnectionStatusListener
|
|
}
|
|
)
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
listSshHostProjectReferences,
|
|
deleteProjectsReferencingSshHost
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined),
|
|
undefined, // onBeforeClearLocalData
|
|
undefined, // browserControl
|
|
undefined, // subagentService
|
|
undefined, // channelSettingsStore
|
|
{
|
|
get: vi.fn(async () => ({ remoteProjectsEnabled: true })),
|
|
onChanged: vi.fn()
|
|
} as never, // applicationSettingsStore
|
|
undefined, // versionChecker
|
|
undefined, // speechModelManager
|
|
undefined, // embeddingIndexCoordinator
|
|
selectedRuntimes as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
sshHostService as never,
|
|
undefined,
|
|
remoteProjectSaveService as never,
|
|
sshHostDirectoryBrowser as never,
|
|
sshHostRemoteEnvironmentInspector as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
remoteEnvironmentUpdateService as never,
|
|
agentPackageManager as never,
|
|
remoteAgentConnectionManager as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const createInput = {
|
|
name: ' Build host ',
|
|
hostname: 'build.example.com',
|
|
port: 22,
|
|
username: 'builder',
|
|
authentication: 'system-agent',
|
|
password: { action: 'clear' }
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event)
|
|
).resolves.toEqual({
|
|
...snapshot,
|
|
projectReferences: {},
|
|
agentConnectionStatusByHostId: {
|
|
[hostId]: 'ready'
|
|
}
|
|
})
|
|
expect(
|
|
remoteAgentConnectionManager.getHostConnectionState
|
|
).toHaveBeenCalledWith(hostId)
|
|
publishAgentConnectionStatus?.({
|
|
hostId,
|
|
state: 'connecting'
|
|
})
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.sshHostsAgentConnectionStatus,
|
|
{
|
|
hostId,
|
|
state: 'connecting'
|
|
}
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsAgentPackageInventory
|
|
)?.(event, { refresh: true })
|
|
).resolves.toEqual(agentPackageInventory)
|
|
expect(
|
|
agentPackageManager.getAllSnapshot
|
|
).toHaveBeenCalledWith({ refresh: true })
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsAgentPackageDownload
|
|
)?.(event, { architecture: 'x64' })
|
|
).resolves.toEqual(agentPackageInventory)
|
|
expect(agentPackageManager.download).toHaveBeenCalledWith(
|
|
'x64',
|
|
expect.any(Function)
|
|
)
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.sshHostsAgentPackageProgress,
|
|
{
|
|
architecture: 'x64',
|
|
platform: 'linux',
|
|
phase: 'downloading',
|
|
completedBytes: 1,
|
|
totalBytes: 2
|
|
}
|
|
)
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: ['C:\\transfer\\agent.gbagent']
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsAgentPackageImport
|
|
)?.(event)
|
|
).resolves.toEqual(agentPackageInventory)
|
|
expect(agentPackageManager.importArchive).toHaveBeenCalledWith(
|
|
'C:\\transfer\\agent.gbagent'
|
|
)
|
|
|
|
electronMocks.showSaveDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePath: 'C:\\transfer\\agent'
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsAgentPackageExport
|
|
)?.(event, { architecture: 'arm64' })
|
|
).resolves.toBeUndefined()
|
|
expect(agentPackageManager.exportArchive).toHaveBeenCalledWith(
|
|
'arm64',
|
|
'C:\\transfer\\agent.gbagent'
|
|
)
|
|
expect(
|
|
agentPackageManager.getExportArchiveName
|
|
).toHaveBeenCalledWith('arm64')
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsInspectDraftKey
|
|
)?.(
|
|
event,
|
|
{
|
|
hostId,
|
|
hostname: createInput.hostname,
|
|
port: createInput.port,
|
|
username: createInput.username
|
|
}
|
|
)
|
|
).resolves.toMatchObject({ fingerprintSha256: fingerprint })
|
|
expect(
|
|
sshHostService.inspectDraftHostKey
|
|
).toHaveBeenCalledWith({
|
|
hostId,
|
|
hostname: createInput.hostname,
|
|
port: createInput.port,
|
|
username: createInput.username
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsValidateAndSave
|
|
)?.(
|
|
event,
|
|
{
|
|
candidateId,
|
|
fingerprintSha256: fingerprint,
|
|
input: createInput
|
|
}
|
|
)
|
|
).resolves.toMatchObject({
|
|
host,
|
|
connection: { connected: true }
|
|
})
|
|
expect(sshHostService.validateAndSave).toHaveBeenCalledWith({
|
|
candidateId,
|
|
fingerprintSha256: fingerprint,
|
|
input: {
|
|
...createInput,
|
|
name: 'Build host'
|
|
}
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsDiscardCandidate
|
|
)?.(event, { candidateId })
|
|
).resolves.toBeUndefined()
|
|
expect(sshHostService.discardCandidate).toHaveBeenCalledWith(
|
|
candidateId
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
|
|
event,
|
|
{ hostId }
|
|
)
|
|
).resolves.toEqual({
|
|
hostId,
|
|
deletedProjects: []
|
|
})
|
|
deleteProjectsReferencingSshHost.mockReturnValue([
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000107',
|
|
name: 'Remote project'
|
|
}
|
|
])
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
|
|
event,
|
|
{ hostId }
|
|
)
|
|
).resolves.toEqual({
|
|
hostId,
|
|
deletedProjects: [
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000107',
|
|
name: 'Remote project'
|
|
}
|
|
]
|
|
})
|
|
expect(sshHostService.remove).toHaveBeenCalledTimes(2)
|
|
sshHostService.remove.mockRejectedValueOnce(
|
|
new Error('Host store unavailable')
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.(
|
|
event,
|
|
{ hostId }
|
|
)
|
|
).rejects.toThrow('Host store unavailable')
|
|
expect(deleteProjectsReferencingSshHost).toHaveBeenCalledTimes(2)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsRemoteEnvironment
|
|
)?.(event, { hostId })
|
|
).resolves.toEqual(remoteEnvironment)
|
|
expect(inspectRemoteEnvironment).toHaveBeenCalledWith(hostId)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsRemoteEnvironment
|
|
)?.(event, { hostId: 'not-a-uuid' })
|
|
).rejects.toThrow()
|
|
expect(inspectRemoteEnvironment).toHaveBeenCalledOnce()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsUpdateRemoteEnvironment
|
|
)?.(event, { hostId, method: 'remote-download' })
|
|
).resolves.toBeUndefined()
|
|
expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledWith(
|
|
webContents,
|
|
{ hostId, method: 'remote-download' },
|
|
expect.any(Function)
|
|
)
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.sshHostsRemoteEnvironmentUpdateProgress,
|
|
{
|
|
hostId,
|
|
method: 'remote-download',
|
|
phase: 'verifying'
|
|
}
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsUpdateRemoteEnvironment
|
|
)?.(event, {
|
|
hostId,
|
|
method: 'remote-download',
|
|
command: 'whoami'
|
|
})
|
|
).rejects.toThrow()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsUpdateRemoteEnvironment
|
|
)?.(
|
|
{ sender: {}, senderFrame: webContents.mainFrame },
|
|
{ hostId, method: 'remote-download' }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledOnce()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsCancelRemoteEnvironmentUpdate
|
|
)?.(event, { hostId })
|
|
).resolves.toBeUndefined()
|
|
expect(remoteEnvironmentUpdateService.cancel).toHaveBeenCalledWith(
|
|
webContents,
|
|
hostId
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsValidateAndSave
|
|
)?.(event, {
|
|
candidateId: 'not-a-uuid',
|
|
fingerprintSha256: fingerprint,
|
|
input: { ...createInput, command: 'whoami' }
|
|
})
|
|
).rejects.toThrow()
|
|
expect(sshHostService.validateAndSave).toHaveBeenCalledOnce()
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.({
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
})
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsAgentPackageInventory
|
|
)?.({
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
})
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
expect(
|
|
agentPackageManager.getAllSnapshot
|
|
).toHaveBeenCalledTimes(3)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsBrowseDirectories
|
|
)?.(event, { hostId, path: '/srv' })
|
|
).resolves.toEqual(directoryListing)
|
|
expect(
|
|
sshHostDirectoryBrowser.listDirectories
|
|
).toHaveBeenCalledWith(
|
|
hostId,
|
|
'/srv',
|
|
expect.any(AbortSignal)
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsBrowseDirectories
|
|
)?.(event, { hostId, path: '../escape' })
|
|
).rejects.toThrow()
|
|
expect(
|
|
sshHostDirectoryBrowser.listDirectories
|
|
).toHaveBeenCalledOnce()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsBrowseDirectories
|
|
)?.(
|
|
{ sender: {}, senderFrame: webContents.mainFrame },
|
|
{ hostId }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
|
|
let browseSignal: AbortSignal | undefined
|
|
sshHostDirectoryBrowser.listDirectories.mockImplementationOnce(
|
|
async (...args): Promise<typeof directoryListing> => {
|
|
const signal = args[2]
|
|
if (!signal) {
|
|
throw new Error('Expected directory browse AbortSignal')
|
|
}
|
|
return new Promise<typeof directoryListing>((_resolve, reject) => {
|
|
browseSignal = signal
|
|
signal.addEventListener(
|
|
'abort',
|
|
() => reject(signal.reason),
|
|
{ once: true }
|
|
)
|
|
})
|
|
}
|
|
)
|
|
const activeBrowse = electronMocks.handlers.get(
|
|
ipcChannels.sshHostsBrowseDirectories
|
|
)?.(event, { hostId })
|
|
await vi.waitFor(() =>
|
|
expect(browseSignal).toBeInstanceOf(AbortSignal)
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsCancelDirectoryBrowse
|
|
)?.(event)
|
|
).resolves.toBeUndefined()
|
|
await expect(activeBrowse).rejects.toMatchObject({
|
|
name: 'AbortError'
|
|
})
|
|
|
|
const remoteDraft = {
|
|
intent: 'create',
|
|
draft: {
|
|
name: 'Remote project',
|
|
description: '',
|
|
runtimeSelection: { provider: 'opencode' },
|
|
hostId,
|
|
remoteRootPath: '/srv/project'
|
|
}
|
|
}
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectSave
|
|
)?.(event, remoteDraft)
|
|
).resolves.toEqual(remoteProject)
|
|
expect(remoteProjectSaveService.save).toHaveBeenCalledWith(
|
|
webContents,
|
|
remoteDraft
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectCancelCurrent
|
|
)?.(event)
|
|
).resolves.toBeUndefined()
|
|
expect(
|
|
remoteProjectSaveService.cancelCurrent
|
|
).toHaveBeenCalledWith(webContents)
|
|
expect(selectedRuntimes.reset).not.toHaveBeenCalled()
|
|
expect(selectedRuntimes.reset).not.toHaveBeenCalled()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectSave
|
|
)?.(
|
|
{ sender: {}, senderFrame: webContents.mainFrame },
|
|
remoteDraft
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
|
|
await dispose()
|
|
expect(removeAgentConnectionStatusListener).toHaveBeenCalledOnce()
|
|
expect(remoteProjectSaveService.dispose).toHaveBeenCalledOnce()
|
|
expect(
|
|
remoteEnvironmentUpdateService.dispose
|
|
).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('blocks disabled and unavailable remote-project access before remote services', async () => {
|
|
const hostId = '00000000-0000-4000-8000-000000000105'
|
|
const candidateId = '00000000-0000-4000-8000-000000000106'
|
|
const projectId = '00000000-0000-4000-8000-000000000107'
|
|
const fingerprint = `SHA256:${'A'.repeat(43)}`
|
|
const remoteProject = {
|
|
id: projectId,
|
|
name: 'Remote project',
|
|
description: '',
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh' as const,
|
|
hostId,
|
|
remoteRootPath: '/srv/project'
|
|
},
|
|
kind: 'user' as const,
|
|
status: 'active' as const,
|
|
createdAt: '2026-08-01T00:00:00.000Z',
|
|
updatedAt: '2026-08-01T00:00:00.000Z'
|
|
}
|
|
const applicationSettingsStore = {
|
|
onChanged: vi.fn(),
|
|
get: vi.fn(async () => ({ remoteProjectsEnabled: false }))
|
|
}
|
|
const sshHostService = {
|
|
getSnapshot: vi.fn(),
|
|
remove: vi.fn(),
|
|
inspectDraftHostKey: vi.fn(),
|
|
discardCandidate: vi.fn(),
|
|
validateAndSave: vi.fn()
|
|
}
|
|
const remoteProjectSaveService = {
|
|
activate: vi.fn(),
|
|
save: vi.fn(),
|
|
cancelCurrent: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const sshHostDirectoryBrowser = {
|
|
listDirectories: vi.fn()
|
|
}
|
|
const sshHostRemoteEnvironmentInspector = {
|
|
inspect: vi.fn()
|
|
}
|
|
const remoteEnvironmentUpdateService = {
|
|
update: vi.fn(),
|
|
cancel: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const executionSpaceResolver = {
|
|
resolveProject: vi.fn()
|
|
}
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
listSshHostProjectReferences: vi.fn(() => []),
|
|
deleteProjectsReferencingSshHost: vi.fn(() => []),
|
|
getProject: vi.fn(() => remoteProject),
|
|
updateProject: vi.fn(),
|
|
setProjectArchived: vi.fn(),
|
|
deleteProject: vi.fn()
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
applicationSettingsStore as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
sshHostService as never,
|
|
executionSpaceResolver as never,
|
|
remoteProjectSaveService as never,
|
|
sshHostDirectoryBrowser as never,
|
|
sshHostRemoteEnvironmentInspector as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
remoteEnvironmentUpdateService as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const createInput = {
|
|
name: 'Build host',
|
|
hostname: 'build.example.com',
|
|
port: 22,
|
|
username: 'builder',
|
|
authentication: 'system-agent',
|
|
password: { action: 'clear' }
|
|
}
|
|
const remoteDraft = {
|
|
intent: 'create',
|
|
draft: {
|
|
name: remoteProject.name,
|
|
description: '',
|
|
runtimeSelection: { provider: 'opencode' },
|
|
hostId,
|
|
remoteRootPath: '/srv/project'
|
|
}
|
|
}
|
|
const disabledRequests: Array<[string, unknown?]> = [
|
|
[ipcChannels.sshHostsGet],
|
|
[ipcChannels.sshHostsRemove, { hostId }],
|
|
[
|
|
ipcChannels.sshHostsInspectDraftKey,
|
|
{
|
|
hostId,
|
|
hostname: createInput.hostname,
|
|
port: createInput.port,
|
|
username: createInput.username
|
|
}
|
|
],
|
|
[ipcChannels.sshHostsDiscardCandidate, { candidateId }],
|
|
[
|
|
ipcChannels.sshHostsValidateAndSave,
|
|
{ candidateId, fingerprintSha256: fingerprint, input: createInput }
|
|
],
|
|
[ipcChannels.sshHostsRemoteEnvironment, { hostId }],
|
|
[
|
|
ipcChannels.sshHostsUpdateRemoteEnvironment,
|
|
{ hostId, method: 'remote-download' }
|
|
],
|
|
[
|
|
ipcChannels.sshHostsBrowseDirectories,
|
|
{ hostId, path: '/srv' }
|
|
],
|
|
[ipcChannels.remoteProjectSave, remoteDraft]
|
|
]
|
|
for (const [channel, input] of disabledRequests) {
|
|
await expect(
|
|
electronMocks.handlers.get(channel)?.(event, input)
|
|
).rejects.toThrow('远程项目未启用')
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.projectsUpdate)?.(event, {
|
|
projectId,
|
|
input: {
|
|
name: remoteProject.name,
|
|
description: '',
|
|
rootPath: remoteProject.rootPath
|
|
}
|
|
})
|
|
).rejects.toThrow('远程项目未启用')
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.projectsSetArchived)?.(
|
|
event,
|
|
{ projectId, archived: true }
|
|
)
|
|
).rejects.toThrow('远程项目未启用')
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.projectsDelete)?.(event, {
|
|
projectId,
|
|
confirmation: remoteProject.name
|
|
})
|
|
).rejects.toThrow('远程项目未启用')
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.(
|
|
event,
|
|
{ projectId, path: 'README.md' }
|
|
)
|
|
).rejects.toThrow('远程项目未启用')
|
|
|
|
expect(sshHostService.getSnapshot).not.toHaveBeenCalled()
|
|
expect(sshHostService.remove).not.toHaveBeenCalled()
|
|
expect(sshHostService.inspectDraftHostKey).not.toHaveBeenCalled()
|
|
expect(sshHostService.discardCandidate).not.toHaveBeenCalled()
|
|
expect(sshHostService.validateAndSave).not.toHaveBeenCalled()
|
|
expect(
|
|
sshHostRemoteEnvironmentInspector.inspect
|
|
).not.toHaveBeenCalled()
|
|
expect(
|
|
sshHostDirectoryBrowser.listDirectories
|
|
).not.toHaveBeenCalled()
|
|
expect(remoteProjectSaveService.activate).not.toHaveBeenCalled()
|
|
expect(remoteProjectSaveService.save).not.toHaveBeenCalled()
|
|
expect(remoteEnvironmentUpdateService.update).not.toHaveBeenCalled()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsCancelDirectoryBrowse
|
|
)?.(event)
|
|
).resolves.toBeUndefined()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.sshHostsCancelRemoteEnvironmentUpdate
|
|
)?.(event, { hostId })
|
|
).resolves.toBeUndefined()
|
|
expect(remoteEnvironmentUpdateService.cancel).toHaveBeenCalledWith(
|
|
webContents,
|
|
hostId
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectCancelCurrent
|
|
)?.(event)
|
|
).resolves.toBeUndefined()
|
|
expect(remoteProjectSaveService.cancelCurrent).toHaveBeenCalledWith(
|
|
webContents
|
|
)
|
|
expect(
|
|
assistantDatabase.listSshHostProjectReferences
|
|
).not.toHaveBeenCalled()
|
|
expect(
|
|
assistantDatabase.deleteProjectsReferencingSshHost
|
|
).not.toHaveBeenCalled()
|
|
expect(assistantDatabase.updateProject).not.toHaveBeenCalled()
|
|
expect(assistantDatabase.setProjectArchived).not.toHaveBeenCalled()
|
|
expect(assistantDatabase.deleteProject).not.toHaveBeenCalled()
|
|
expect(executionSpaceResolver.resolveProject).not.toHaveBeenCalled()
|
|
|
|
applicationSettingsStore.get.mockRejectedValueOnce(
|
|
new Error('settings unavailable')
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event)
|
|
).rejects.toThrow('远程项目未启用')
|
|
expect(sshHostService.getSnapshot).not.toHaveBeenCalled()
|
|
|
|
await dispose()
|
|
})
|
|
|
|
it('sends save progress only to a live owning WebContents', () => {
|
|
const owner = {
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn(),
|
|
once: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const progress = { phase: 'agent' as const }
|
|
|
|
sendRemoteProjectSaveProgress(owner as never, progress)
|
|
expect(owner.send).toHaveBeenCalledWith(
|
|
ipcChannels.remoteProjectSaveProgress,
|
|
progress
|
|
)
|
|
owner.isDestroyed.mockReturnValue(true)
|
|
sendRemoteProjectSaveProgress(owner as never, progress)
|
|
expect(owner.send).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('sends validated update progress only to a live owning WebContents', () => {
|
|
const owner = {
|
|
id: 1,
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn(),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const progress = {
|
|
hostId: '00000000-0000-4000-8000-000000000105',
|
|
method: 'remote-download' as const,
|
|
phase: 'installing-runtime' as const
|
|
}
|
|
|
|
sendRemoteEnvironmentUpdateProgress(owner as never, progress)
|
|
expect(owner.send).toHaveBeenCalledWith(
|
|
ipcChannels.sshHostsRemoteEnvironmentUpdateProgress,
|
|
progress
|
|
)
|
|
expect(() =>
|
|
sendRemoteEnvironmentUpdateProgress(owner as never, {
|
|
...progress,
|
|
phase: 'uploading'
|
|
} as never)
|
|
).toThrow()
|
|
owner.isDestroyed.mockReturnValue(true)
|
|
sendRemoteEnvironmentUpdateProgress(owner as never, progress)
|
|
expect(owner.send).toHaveBeenCalledOnce()
|
|
})
|
|
})
|
|
|
|
function runtimeUpdateFixture(
|
|
workspacePath: string,
|
|
provider: 'model' | 'opencode' | 'continue' = 'model'
|
|
) {
|
|
const profileId = '00000000-0000-4000-8000-000000000095'
|
|
const profile = {
|
|
id: profileId,
|
|
name: 'Default',
|
|
baseUrl: 'https://model.example/v1',
|
|
modelName: 'model',
|
|
protocol: 'openai-chat-completions' as const,
|
|
authentication: 'api-key' as const,
|
|
imageGenerationQuality: 'auto' as const,
|
|
apiKeyConfigured: true,
|
|
credentialSource: 'encrypted' as const
|
|
}
|
|
const publicSettings = {
|
|
provider,
|
|
modelBaseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
modelProtocol: profile.protocol,
|
|
modelAuthentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: '',
|
|
continueConfigPath: '',
|
|
continueMode: 'chat' as const,
|
|
subagentSmartRoutingEnabled: false,
|
|
knowledgeEmbeddingEnabled: false,
|
|
knowledgeEmbeddingBaseUrl: 'https://embedding.example/v1',
|
|
knowledgeEmbeddingModel: 'embedding',
|
|
knowledgeEmbeddingApiKeyConfigured: false,
|
|
knowledgeEmbeddingCredentialSource: 'none' as const,
|
|
knowledgeRerankEnabled: false,
|
|
knowledgeRerankEndpoint: 'https://rerank.example/v1',
|
|
knowledgeRerankModel: 'rerank',
|
|
knowledgeRerankApiKeyConfigured: false,
|
|
knowledgeRerankCredentialSource: 'none' as const,
|
|
workspacePath,
|
|
apiKeyConfigured: true,
|
|
credentialSource: 'encrypted' as const,
|
|
modelProfiles: [profile],
|
|
defaultModelProfileId: profileId,
|
|
opencodeModelSource: { kind: 'platform' as const },
|
|
continueModelSource: { kind: 'platform' as const },
|
|
deepseekHarnessModelSource: { kind: 'platform' as const },
|
|
secureStorageAvailable: true
|
|
}
|
|
return {
|
|
publicSettings,
|
|
input: {
|
|
provider,
|
|
modelBaseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
modelProtocol: profile.protocol,
|
|
modelAuthentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: '',
|
|
continueConfigPath: '',
|
|
continueMode: 'chat' as const,
|
|
subagentSmartRoutingEnabled: false,
|
|
knowledgeEmbeddingEnabled: false,
|
|
knowledgeEmbeddingBaseUrl:
|
|
publicSettings.knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel:
|
|
publicSettings.knowledgeEmbeddingModel,
|
|
knowledgeRerankEnabled: false,
|
|
knowledgeRerankEndpoint:
|
|
publicSettings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel: publicSettings.knowledgeRerankModel,
|
|
workspacePath,
|
|
apiKey: { action: 'keep' as const },
|
|
modelProfiles: [
|
|
{
|
|
id: profile.id,
|
|
name: profile.name,
|
|
baseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
protocol: profile.protocol,
|
|
authentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
apiKey: { action: 'keep' as const }
|
|
}
|
|
],
|
|
defaultModelProfileId: profileId,
|
|
opencodeModelSource: { kind: 'platform' as const },
|
|
continueModelSource: { kind: 'platform' as const },
|
|
deepseekHarnessModelSource: { kind: 'platform' as const }
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('registerIpcHandlers lifecycle tracking', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
channelMocks.stop.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('waits for a pending settings update and Runtime reload during cleanup', async () => {
|
|
let releaseUpdate!: () => void
|
|
const updateReleased = new Promise<void>((resolve) => {
|
|
releaseUpdate = resolve
|
|
})
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), 'goodbuddy-ipc-settings-')
|
|
)
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const savedSettings = {
|
|
provider: 'model',
|
|
modelBaseUrl: 'https://bigtoken.ai',
|
|
modelName: 'sonnet-5',
|
|
modelProtocol: 'anthropic-messages',
|
|
modelAuthentication: 'api-key',
|
|
imageGenerationQuality: 'auto',
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: '',
|
|
continueConfigPath: '',
|
|
continueMode: 'chat',
|
|
subagentSmartRoutingEnabled: false,
|
|
knowledgeEmbeddingEnabled: false,
|
|
knowledgeEmbeddingBaseUrl:
|
|
'http://127.0.0.1:11434/v1/embeddings',
|
|
knowledgeEmbeddingModel: 'nomic-embed-text',
|
|
knowledgeEmbeddingApiKeyConfigured: false,
|
|
knowledgeEmbeddingCredentialSource: 'none',
|
|
knowledgeRerankEnabled: false,
|
|
knowledgeRerankEndpoint: 'https://api.cohere.com/v1/rerank',
|
|
knowledgeRerankModel: 'rerank-v3.5',
|
|
knowledgeRerankApiKeyConfigured: false,
|
|
knowledgeRerankCredentialSource: 'none',
|
|
workspacePath: workspace,
|
|
apiKeyConfigured: false,
|
|
credentialSource: 'none',
|
|
modelProfiles: [],
|
|
defaultModelProfileId: '00000000-0000-4000-8000-000000000001',
|
|
opencodeModelSource: { kind: 'platform' },
|
|
continueModelSource: { kind: 'platform' },
|
|
secureStorageAvailable: true
|
|
}
|
|
const update = vi.fn(async () => {
|
|
await updateReleased
|
|
return savedSettings
|
|
})
|
|
let releaseReload!: () => void
|
|
const reloadReleased = new Promise<void>((resolve) => {
|
|
releaseReload = resolve
|
|
})
|
|
const onRuntimeSettingsChanged = vi.fn(async () => {
|
|
await reloadReleased
|
|
})
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{
|
|
captureRollback: vi.fn(async () => ({
|
|
publicSettings: savedSettings,
|
|
restore: vi.fn(async () => savedSettings)
|
|
})),
|
|
update
|
|
} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
repairConversationRuntimeSelections: vi.fn()
|
|
} as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const input = {
|
|
provider: savedSettings.provider,
|
|
modelBaseUrl: savedSettings.modelBaseUrl,
|
|
modelName: savedSettings.modelName,
|
|
modelProtocol: savedSettings.modelProtocol,
|
|
modelAuthentication: savedSettings.modelAuthentication,
|
|
imageGenerationQuality: savedSettings.imageGenerationQuality,
|
|
opencodeBaseUrl: savedSettings.opencodeBaseUrl,
|
|
opencodeEmbedded: savedSettings.opencodeEmbedded,
|
|
opencodeBinaryPath: savedSettings.opencodeBinaryPath,
|
|
opencodeConfigPath: savedSettings.opencodeConfigPath,
|
|
continueBinaryPath: savedSettings.continueBinaryPath,
|
|
continueConfigPath: savedSettings.continueConfigPath,
|
|
continueMode: savedSettings.continueMode,
|
|
knowledgeEmbeddingEnabled:
|
|
savedSettings.knowledgeEmbeddingEnabled,
|
|
knowledgeEmbeddingBaseUrl:
|
|
savedSettings.knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel: savedSettings.knowledgeEmbeddingModel,
|
|
knowledgeRerankEnabled: savedSettings.knowledgeRerankEnabled,
|
|
knowledgeRerankEndpoint: savedSettings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel: savedSettings.knowledgeRerankModel,
|
|
workspacePath: savedSettings.workspacePath,
|
|
apiKey: { action: 'keep' as const }
|
|
}
|
|
|
|
try {
|
|
const pendingUpdate = Promise.resolve(
|
|
electronMocks.handlers.get(ipcChannels.runtimeSettingsUpdate)?.(
|
|
event,
|
|
input
|
|
)
|
|
)
|
|
await vi.waitFor(() => expect(update).toHaveBeenCalledOnce())
|
|
|
|
let cleanupComplete = false
|
|
const cleanup = dispose().then(() => {
|
|
cleanupComplete = true
|
|
})
|
|
await Promise.resolve()
|
|
expect(cleanupComplete).toBe(false)
|
|
|
|
releaseUpdate()
|
|
await vi.waitFor(() =>
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
)
|
|
expect(cleanupComplete).toBe(false)
|
|
|
|
releaseReload()
|
|
await expect(pendingUpdate).resolves.toBe(savedSettings)
|
|
await cleanup
|
|
expect(cleanupComplete).toBe(true)
|
|
} finally {
|
|
releaseUpdate()
|
|
releaseReload()
|
|
await rm(workspace, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('restores settings and preserves activation plus rollback failures', async () => {
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), 'goodbuddy-ipc-settings-rollback-')
|
|
)
|
|
const profileId = '00000000-0000-4000-8000-000000000091'
|
|
const profile = {
|
|
id: profileId,
|
|
name: 'Default',
|
|
baseUrl: 'https://model.example/v1',
|
|
modelName: 'model',
|
|
protocol: 'openai-chat-completions' as const,
|
|
authentication: 'api-key' as const,
|
|
imageGenerationQuality: 'auto' as const,
|
|
apiKeyConfigured: true,
|
|
credentialSource: 'encrypted' as const
|
|
}
|
|
const previousSettings = {
|
|
provider: 'model' as const,
|
|
modelBaseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
modelProtocol: profile.protocol,
|
|
modelAuthentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: '',
|
|
continueConfigPath: '',
|
|
continueMode: 'chat' as const,
|
|
subagentSmartRoutingEnabled: false,
|
|
knowledgeEmbeddingEnabled: false,
|
|
knowledgeEmbeddingBaseUrl: 'https://embedding.example/v1',
|
|
knowledgeEmbeddingModel: 'embedding',
|
|
knowledgeEmbeddingApiKeyConfigured: false,
|
|
knowledgeEmbeddingCredentialSource: 'none' as const,
|
|
knowledgeRerankEnabled: false,
|
|
knowledgeRerankEndpoint: 'https://rerank.example/v1',
|
|
knowledgeRerankModel: 'rerank',
|
|
knowledgeRerankApiKeyConfigured: false,
|
|
knowledgeRerankCredentialSource: 'none' as const,
|
|
workspacePath: workspace,
|
|
apiKeyConfigured: true,
|
|
credentialSource: 'encrypted' as const,
|
|
modelProfiles: [profile],
|
|
defaultModelProfileId: profileId,
|
|
opencodeModelSource: { kind: 'platform' as const },
|
|
continueModelSource: { kind: 'platform' as const },
|
|
deepseekHarnessModelSource: { kind: 'platform' as const },
|
|
secureStorageAvailable: true
|
|
}
|
|
const candidateSettings = {
|
|
...previousSettings,
|
|
provider: 'opencode' as const
|
|
}
|
|
const update = vi.fn().mockResolvedValue(candidateSettings)
|
|
const restore = vi.fn(async () => previousSettings)
|
|
const activationError = new Error('candidate activation failed')
|
|
const rollbackError = new Error('rollback activation failed')
|
|
const onRuntimeSettingsChanged = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(activationError)
|
|
.mockRejectedValueOnce(rollbackError)
|
|
const repairs = vi.fn()
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{
|
|
captureRollback: vi.fn(async () => ({
|
|
publicSettings: previousSettings,
|
|
restore
|
|
})),
|
|
update
|
|
} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
repairConversationRuntimeSelections: repairs
|
|
} as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const input = {
|
|
provider: candidateSettings.provider,
|
|
modelBaseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
modelProtocol: profile.protocol,
|
|
modelAuthentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeBinaryPath: '',
|
|
opencodeConfigPath: '',
|
|
continueBinaryPath: '',
|
|
continueConfigPath: '',
|
|
continueMode: 'chat',
|
|
subagentSmartRoutingEnabled: false,
|
|
knowledgeEmbeddingEnabled: false,
|
|
knowledgeEmbeddingBaseUrl:
|
|
previousSettings.knowledgeEmbeddingBaseUrl,
|
|
knowledgeEmbeddingModel:
|
|
previousSettings.knowledgeEmbeddingModel,
|
|
knowledgeRerankEnabled: false,
|
|
knowledgeRerankEndpoint:
|
|
previousSettings.knowledgeRerankEndpoint,
|
|
knowledgeRerankModel:
|
|
previousSettings.knowledgeRerankModel,
|
|
workspacePath: workspace,
|
|
apiKey: { action: 'keep' },
|
|
modelProfiles: [
|
|
{
|
|
id: profile.id,
|
|
name: profile.name,
|
|
baseUrl: profile.baseUrl,
|
|
modelName: profile.modelName,
|
|
protocol: profile.protocol,
|
|
authentication: profile.authentication,
|
|
imageGenerationQuality: profile.imageGenerationQuality,
|
|
apiKey: { action: 'keep' }
|
|
}
|
|
],
|
|
defaultModelProfileId: profileId,
|
|
opencodeModelSource: { kind: 'platform' },
|
|
continueModelSource: { kind: 'platform' },
|
|
deepseekHarnessModelSource: { kind: 'platform' }
|
|
}
|
|
|
|
try {
|
|
const result = Promise.resolve(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsUpdate
|
|
)?.(event, input)
|
|
)
|
|
await expect(result).rejects.toMatchObject({
|
|
errors: [activationError, rollbackError]
|
|
})
|
|
expect(update).toHaveBeenCalledOnce()
|
|
expect(restore).toHaveBeenCalledOnce()
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(2)
|
|
expect(repairs).not.toHaveBeenCalled()
|
|
} finally {
|
|
await dispose()
|
|
await rm(workspace, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('serializes failed settings, customization, and a later successful candidate', async () => {
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), 'goodbuddy-ipc-settings-overlap-')
|
|
)
|
|
const initial = runtimeUpdateFixture(workspace, 'model')
|
|
const candidateA = runtimeUpdateFixture(workspace, 'opencode')
|
|
const candidateB = runtimeUpdateFixture(workspace, 'continue')
|
|
let currentSettings = initial.publicSettings
|
|
const initialCustomization = {
|
|
opencode: {},
|
|
continue: { presets: [] }
|
|
}
|
|
const candidateCustomization = {
|
|
opencode: { defaultAgent: 'candidate-agent' },
|
|
continue: { presets: [] }
|
|
}
|
|
let currentCustomization = initialCustomization
|
|
const captureRollback = vi.fn(async () => {
|
|
const snapshot = currentSettings
|
|
return {
|
|
publicSettings: snapshot,
|
|
restore: vi.fn(async () => {
|
|
currentSettings = snapshot
|
|
return snapshot
|
|
})
|
|
}
|
|
})
|
|
const update = vi.fn(
|
|
async (input: { provider: 'model' | 'opencode' | 'continue' }) => {
|
|
currentSettings =
|
|
input.provider === 'opencode'
|
|
? candidateA.publicSettings
|
|
: candidateB.publicSettings
|
|
return currentSettings
|
|
}
|
|
)
|
|
const updateRuntimeCustomization = vi.fn(
|
|
async (settings: typeof candidateCustomization) => {
|
|
currentCustomization = settings
|
|
return settings
|
|
}
|
|
)
|
|
let releaseCandidateA!: () => void
|
|
const candidateABlocked = new Promise<void>((resolve) => {
|
|
releaseCandidateA = resolve
|
|
})
|
|
const candidateAError = new Error('candidate A activation failed')
|
|
let activationCount = 0
|
|
const onRuntimeSettingsChanged = vi.fn(async () => {
|
|
activationCount += 1
|
|
if (activationCount === 1) {
|
|
await candidateABlocked
|
|
throw candidateAError
|
|
}
|
|
})
|
|
const repairs = vi.fn(() => [])
|
|
const reportRepairs = vi.fn()
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{
|
|
captureRollback,
|
|
update,
|
|
getRuntimeCustomization: vi.fn(
|
|
async () => currentCustomization
|
|
),
|
|
updateRuntimeCustomization
|
|
} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
repairConversationRuntimeSelections: repairs
|
|
} as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
reportRuntimeSelectionRepairs: reportRepairs
|
|
} as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
try {
|
|
const updateA = Promise.resolve(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsUpdate
|
|
)?.(event, candidateA.input)
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
)
|
|
const customizationUpdate = Promise.resolve(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationUpdate
|
|
)?.(event, candidateCustomization)
|
|
)
|
|
const updateB = Promise.resolve(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsUpdate
|
|
)?.(event, candidateB.input)
|
|
)
|
|
await Promise.resolve()
|
|
|
|
expect(captureRollback).toHaveBeenCalledOnce()
|
|
expect(update).toHaveBeenCalledOnce()
|
|
|
|
releaseCandidateA()
|
|
await expect(updateA).rejects.toBe(candidateAError)
|
|
await expect(customizationUpdate).resolves.toEqual(
|
|
candidateCustomization
|
|
)
|
|
await expect(updateB).resolves.toMatchObject({
|
|
provider: 'continue'
|
|
})
|
|
|
|
expect(currentSettings.provider).toBe('continue')
|
|
expect(currentCustomization).toEqual(candidateCustomization)
|
|
expect(captureRollback).toHaveBeenCalledTimes(2)
|
|
expect(update).toHaveBeenCalledTimes(2)
|
|
expect(updateRuntimeCustomization).toHaveBeenCalledOnce()
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(4)
|
|
expect(repairs).toHaveBeenCalledOnce()
|
|
expect(repairs).toHaveBeenCalledWith(
|
|
expect.objectContaining({ provider: 'continue' })
|
|
)
|
|
expect(reportRepairs).toHaveBeenCalledOnce()
|
|
} finally {
|
|
releaseCandidateA()
|
|
await dispose()
|
|
await rm(workspace, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers knowledge snapshot ontology', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('exposes per-library ontology settings and rebuild state', async () => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
const knowledgeService = {
|
|
database: { externalStore: { listBindings: () => [] } },
|
|
snapshot: vi.fn(() => ({
|
|
libraries: [
|
|
{
|
|
id: libraryId,
|
|
name: 'Ontology',
|
|
description: '',
|
|
storageMode: 'reference',
|
|
graphEnabled: true,
|
|
graphStrategy: 'rules',
|
|
sourceCount: 0,
|
|
documentCount: 0,
|
|
indexedDocumentCount: 0,
|
|
retrievalSettings: {},
|
|
chunkingSettings: {},
|
|
chunkingRebuildRequired: false,
|
|
ontologySettings: defaultKnowledgeOntologySettings,
|
|
ontologyRebuildRequired: true,
|
|
updatedAt: '2026-08-12T00:00:00.000Z'
|
|
}
|
|
],
|
|
sources: [],
|
|
documents: [],
|
|
entities: [],
|
|
relations: [],
|
|
evidence: [],
|
|
tasks: []
|
|
}))
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
knowledgeService as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined)
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
expect(
|
|
await electronMocks.handlers.get(ipcChannels.knowledgeSnapshot)?.(
|
|
event,
|
|
libraryId
|
|
)
|
|
).toMatchObject({
|
|
libraries: [
|
|
{
|
|
id: libraryId,
|
|
ontologySettings: defaultKnowledgeOntologySettings,
|
|
ontologyRebuildRequired: true
|
|
}
|
|
]
|
|
})
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers knowledge embedding index', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates and forwards library-scoped index actions', async () => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
const jobId = '22222222-2222-4222-8222-222222222222'
|
|
const snapshot = {
|
|
knowledgeBaseId: libraryId,
|
|
enabled: true,
|
|
configuration: {
|
|
provider: 'openai-compatible',
|
|
model: 'embed-v1',
|
|
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
|
|
credentialConfigured: false
|
|
},
|
|
coverage: { total: 2, indexed: 1, missing: 1, error: 0 },
|
|
indexStatus: { job: null }
|
|
}
|
|
const knowledgeService = {
|
|
getEmbeddingIndexSnapshot: vi.fn(async () => snapshot),
|
|
rebuildEmbeddingIndex: vi.fn(async () => snapshot),
|
|
cancelEmbeddingIndex: vi.fn(async () => true)
|
|
}
|
|
const settingsStore = {
|
|
getResolvedSettings: vi.fn(async () => ({
|
|
knowledgeEmbeddingEnabled: true
|
|
})),
|
|
getPublicSettings: vi.fn(async () => ({
|
|
knowledgeEmbeddingModel: 'embed-v1',
|
|
knowledgeEmbeddingBaseUrl:
|
|
'http://127.0.0.1:11434/v1/embeddings',
|
|
knowledgeEmbeddingApiKeyConfigured: false
|
|
}))
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
settingsStore as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
knowledgeService as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined)
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeEmbeddingIndexGet
|
|
)?.(event, { knowledgeBaseId: libraryId })
|
|
).resolves.toEqual(snapshot)
|
|
expect(
|
|
knowledgeService.getEmbeddingIndexSnapshot
|
|
).toHaveBeenCalledWith(
|
|
libraryId,
|
|
snapshot.configuration
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeEmbeddingIndexRebuild
|
|
)?.(event, { knowledgeBaseId: libraryId })
|
|
).resolves.toEqual(snapshot)
|
|
expect(knowledgeService.rebuildEmbeddingIndex).toHaveBeenCalledWith(
|
|
libraryId,
|
|
snapshot.configuration
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeEmbeddingIndexCancel
|
|
)?.(event, { knowledgeBaseId: libraryId, jobId })
|
|
).resolves.toBe(true)
|
|
expect(knowledgeService.cancelEmbeddingIndex).toHaveBeenCalledWith(
|
|
libraryId,
|
|
jobId
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeEmbeddingIndexGet
|
|
)?.(event, { knowledgeBaseId: 'not-a-uuid' })
|
|
).rejects.toThrow()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeEmbeddingIndexGet
|
|
)?.(
|
|
{ sender: {}, senderFrame: webContents.mainFrame },
|
|
{ knowledgeBaseId: libraryId }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers knowledge task actions', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates and forwards bounded cancel and retry actions', async () => {
|
|
const taskId = '33333333-3333-4333-8333-333333333333'
|
|
const knowledgeService = {
|
|
cancelTask: vi.fn(async () => true),
|
|
retryTask: vi.fn(async () => undefined)
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
knowledgeService as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined)
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeTaskCancel
|
|
)?.(event, { taskId })
|
|
).resolves.toBe(true)
|
|
expect(knowledgeService.cancelTask).toHaveBeenCalledWith(taskId)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeTaskRetry
|
|
)?.(event, { taskId })
|
|
).resolves.toBeUndefined()
|
|
expect(knowledgeService.retryTask).toHaveBeenCalledWith(taskId)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeTaskCancel
|
|
)?.(event, { taskId: 'not-a-uuid' })
|
|
).rejects.toThrow()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.knowledgeTaskRetry
|
|
)?.(
|
|
{ sender: {}, senderFrame: webContents.mainFrame },
|
|
{ taskId }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers model ZIP dialogs', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('imports and exports speech and OCR ZIPs through trusted dialogs', async () => {
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const speechSnapshot = {
|
|
catalog: [],
|
|
installed: [],
|
|
operations: []
|
|
}
|
|
const speechModelManager = {
|
|
rootDirectory: 'C:\\models\\speech',
|
|
importArchive: vi.fn(async () => speechSnapshot),
|
|
exportArchive: vi.fn(async () => undefined),
|
|
getSnapshot: vi.fn(async () => speechSnapshot),
|
|
cancel: vi.fn()
|
|
}
|
|
const ocrSnapshot = {
|
|
settings: {},
|
|
models: {
|
|
catalog: [],
|
|
installed: [],
|
|
operations: []
|
|
}
|
|
}
|
|
const documentParsingService = {
|
|
snapshot: vi.fn(async () => ocrSnapshot)
|
|
}
|
|
const documentOcrModelManager = {
|
|
importArchive: vi.fn(async () => undefined),
|
|
exportArchive: vi.fn(async () => undefined)
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
speechModelManager as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
documentParsingService as never,
|
|
documentOcrModelManager as never
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.speechModelsImportArchive
|
|
)?.(event, { modelId: 'speech-model' })
|
|
).resolves.toBeUndefined()
|
|
expect(speechModelManager.importArchive).not.toHaveBeenCalled()
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: ['C:\\transfer\\speech.zip']
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.speechModelsImportArchive
|
|
)?.(event, { modelId: 'speech-model' })
|
|
).resolves.toBe(speechSnapshot)
|
|
expect(electronMocks.showOpenDialog).toHaveBeenLastCalledWith(
|
|
window,
|
|
expect.objectContaining({
|
|
properties: ['openFile'],
|
|
filters: [
|
|
{
|
|
name: 'GoodBuddy 模型 ZIP',
|
|
extensions: ['zip']
|
|
}
|
|
]
|
|
})
|
|
)
|
|
expect(speechModelManager.importArchive).toHaveBeenCalledWith(
|
|
'speech-model',
|
|
'C:\\transfer\\speech.zip'
|
|
)
|
|
|
|
electronMocks.showSaveDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePath: 'C:\\transfer\\speech-model'
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.speechModelsExportArchive
|
|
)?.(event, { modelId: 'speech-model' })
|
|
).resolves.toBe(speechSnapshot)
|
|
expect(speechModelManager.exportArchive).toHaveBeenCalledWith(
|
|
'speech-model',
|
|
'C:\\transfer\\speech-model.zip'
|
|
)
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: ['C:\\transfer\\ocr.zip']
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentOcrModelsImportArchive
|
|
)?.(event, { modelId: 'ocr-model' })
|
|
).resolves.toBe(ocrSnapshot)
|
|
expect(documentOcrModelManager.importArchive).toHaveBeenCalledWith(
|
|
'ocr-model',
|
|
'C:\\transfer\\ocr.zip'
|
|
)
|
|
|
|
electronMocks.showSaveDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePath: 'C:\\transfer\\ocr-model.ZIP'
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentOcrModelsExportArchive
|
|
)?.(event, { modelId: 'ocr-model' })
|
|
).resolves.toBe(ocrSnapshot)
|
|
expect(documentOcrModelManager.exportArchive).toHaveBeenCalledWith(
|
|
'ocr-model',
|
|
'C:\\transfer\\ocr-model.ZIP'
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.speechModelsExportArchive
|
|
)?.(
|
|
{
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
{ modelId: 'speech-model' }
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentOcrModelsImportArchive
|
|
)?.(event, {})
|
|
).rejects.toThrow()
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers document parsing', () => {
|
|
const temporaryDirectories: string[] = []
|
|
|
|
afterEach(async () => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) =>
|
|
rm(directory, { recursive: true, force: true })
|
|
)
|
|
)
|
|
})
|
|
|
|
it('tests an explicit scenario and parses artifact PDFs through the shared service', async () => {
|
|
const directory = await mkdtemp(
|
|
join(tmpdir(), 'goodbuddy-ipc-document-parsing-')
|
|
)
|
|
temporaryDirectories.push(directory)
|
|
const diagnosticPath = join(directory, 'diagnostic.pdf')
|
|
const artifactPath = join(directory, 'artifact.pdf')
|
|
const oversizedArtifactPath = join(directory, 'oversized.pdf')
|
|
await writeFile(diagnosticPath, 'diagnostic')
|
|
await writeFile(artifactPath, 'artifact')
|
|
await writeFile(
|
|
oversizedArtifactPath,
|
|
Buffer.alloc(20 * 1024 * 1024 + 1)
|
|
)
|
|
const diagnostic = {
|
|
fileName: 'diagnostic.pdf',
|
|
sourceFormat: 'PDF',
|
|
pageCount: 1,
|
|
ocrPageCount: 0,
|
|
characterCount: 4,
|
|
method: 'native',
|
|
durationMs: 1,
|
|
preview: 'text',
|
|
warnings: []
|
|
}
|
|
const documentParsingService = {
|
|
diagnose: vi.fn(async () => diagnostic),
|
|
parse: vi.fn(async () => ({
|
|
title: 'artifact',
|
|
sourceFormat: '.pdf',
|
|
content: 'parsed artifact',
|
|
sections: [
|
|
{
|
|
locator: '第 1 页',
|
|
content: 'parsed artifact',
|
|
method: 'native'
|
|
}
|
|
],
|
|
pageCount: 1,
|
|
warnings: []
|
|
}))
|
|
}
|
|
const createInlineArtifact = vi.fn((input) => input)
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
createInlineArtifact
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{} as never,
|
|
vi.fn(async () => undefined),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
documentParsingService as never
|
|
)
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: [diagnosticPath]
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentParsingTest
|
|
)?.(event, { purpose: 'knowledge-index' })
|
|
).resolves.toBe(diagnostic)
|
|
expect(documentParsingService.diagnose).toHaveBeenCalledWith(
|
|
'diagnostic.pdf',
|
|
expect.any(Buffer),
|
|
'knowledge-index',
|
|
expect.any(String)
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.documentParsingTest
|
|
)?.(event, { purpose: 'diagnostic' })
|
|
).rejects.toThrow()
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: [artifactPath]
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.artifactsImportFiles
|
|
)?.(event)
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
title: 'artifact.pdf',
|
|
content: '## 第 1 页\n\nparsed artifact'
|
|
})
|
|
])
|
|
expect(documentParsingService.parse).toHaveBeenCalledWith(
|
|
'artifact.pdf',
|
|
expect.any(Buffer),
|
|
'artifact-import'
|
|
)
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
|
canceled: false,
|
|
filePaths: [oversizedArtifactPath]
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.artifactsImportFiles
|
|
)?.(event)
|
|
).rejects.toThrow('超过大小限制')
|
|
expect(documentParsingService.parse).toHaveBeenCalledOnce()
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers connection tests', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('tests a resolved model profile without invoking the selected Continue Runtime', async () => {
|
|
const profileId = '00000000-0000-4000-8000-000000000001'
|
|
const profile = {
|
|
id: profileId,
|
|
name: '默认模型',
|
|
baseUrl: 'https://models.example',
|
|
modelName: 'good-model',
|
|
protocol: 'anthropic-messages',
|
|
authentication: 'api-key',
|
|
imageGenerationQuality: 'auto',
|
|
apiKey: 'main-only-secret' as string | undefined
|
|
}
|
|
const resolvedSettings = {
|
|
provider: 'continue',
|
|
workspacePath: 'C:\\Workspace',
|
|
modelProfiles: [profile],
|
|
defaultModelProfileId: profileId
|
|
}
|
|
const modelRuntime = {
|
|
testConnection: vi.fn(async () => ({
|
|
id: 'model',
|
|
label: 'good-model',
|
|
available: true,
|
|
supportsToolExecution: true,
|
|
detail: 'Ready'
|
|
})),
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(
|
|
modelRuntime
|
|
)
|
|
const continueRuntime = {
|
|
testConnection: vi.fn(async () => {
|
|
throw new Error('Continue 配置不可用')
|
|
}),
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const getResolvedSettings = vi.fn(async () => resolvedSettings)
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const contextManager = { clear: vi.fn() }
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
continueRuntime as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{ getResolvedSettings } as never,
|
|
{} as never,
|
|
contextManager as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsTestModel
|
|
)?.(event, profileId)
|
|
).resolves.toMatchObject({
|
|
id: 'model',
|
|
label: 'good-model',
|
|
available: true
|
|
})
|
|
expect(getResolvedSettings).toHaveBeenCalledOnce()
|
|
expect(
|
|
runtimeFactoryMocks.createModelProfileRuntime
|
|
).toHaveBeenCalledWith('C:\\Workspace', resolvedSettings, profile)
|
|
expect(modelRuntime.testConnection).toHaveBeenCalledOnce()
|
|
expect(modelRuntime.dispose).toHaveBeenCalledOnce()
|
|
expect(continueRuntime.testConnection).not.toHaveBeenCalled()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsTestModel
|
|
)?.(event, 'not-a-profile-id')
|
|
).rejects.toThrow()
|
|
expect(getResolvedSettings).toHaveBeenCalledOnce()
|
|
|
|
getResolvedSettings.mockResolvedValueOnce({
|
|
...resolvedSettings,
|
|
modelProfiles: [{ ...profile, apiKey: undefined }]
|
|
})
|
|
runtimeFactoryMocks.createModelProfileRuntime.mockClear()
|
|
modelRuntime.testConnection.mockClear()
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsTestModel
|
|
)?.(event, profileId)
|
|
).rejects.toThrow('模型连接“默认模型”未配置 API Key')
|
|
expect(getResolvedSettings).toHaveBeenCalledTimes(2)
|
|
expect(
|
|
runtimeFactoryMocks.createModelProfileRuntime
|
|
).not.toHaveBeenCalled()
|
|
expect(modelRuntime.testConnection).not.toHaveBeenCalled()
|
|
|
|
const noAuthProfile = {
|
|
...profile,
|
|
authentication: 'none',
|
|
apiKey: undefined
|
|
}
|
|
const noAuthSettings = {
|
|
...resolvedSettings,
|
|
modelProfiles: [noAuthProfile]
|
|
}
|
|
getResolvedSettings.mockResolvedValueOnce(noAuthSettings)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsTestModel
|
|
)?.(event, profileId)
|
|
).resolves.toMatchObject({ available: true })
|
|
expect(
|
|
runtimeFactoryMocks.createModelProfileRuntime
|
|
).toHaveBeenCalledWith(
|
|
'C:\\Workspace',
|
|
noAuthSettings,
|
|
noAuthProfile
|
|
)
|
|
expect(modelRuntime.testConnection).toHaveBeenCalledOnce()
|
|
|
|
await dispose()
|
|
})
|
|
|
|
it('validates and tests the selected OpenCode or Continue Runtime', async () => {
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(),
|
|
getStatus: vi.fn(),
|
|
testStatus: vi.fn(async () => ({
|
|
id: 'opencode',
|
|
label: 'OpenCode',
|
|
available: true,
|
|
supportsToolExecution: true,
|
|
detail: 'Ready'
|
|
})),
|
|
releaseConversation: vi.fn(async () => undefined)
|
|
}
|
|
const fallbackRuntime = {
|
|
testConnection: vi.fn(async () => {
|
|
throw new Error('不应测试旧的全局 Runtime')
|
|
}),
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
fallbackRuntime as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => {}),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
selectedRuntimes as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const selection = {
|
|
provider: 'opencode' as const,
|
|
profileId: '00000000-0000-4000-8000-000000000001'
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.runtimeSettingsTest)?.(
|
|
event,
|
|
selection
|
|
)
|
|
).resolves.toMatchObject({ id: 'opencode', available: true })
|
|
expect(selectedRuntimes.testStatus).toHaveBeenCalledWith(selection)
|
|
expect(fallbackRuntime.testConnection).not.toHaveBeenCalled()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.runtimeSettingsTest)?.(
|
|
event,
|
|
{ provider: 'opencode', profileId: 'not-a-uuid' }
|
|
)
|
|
).rejects.toThrow()
|
|
expect(selectedRuntimes.testStatus).toHaveBeenCalledOnce()
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers Runtime config actions', () => {
|
|
const temporaryDirectories: string[] = []
|
|
|
|
afterEach(async () => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) =>
|
|
rm(directory, { recursive: true, force: true })
|
|
)
|
|
)
|
|
})
|
|
|
|
it('opens only configured files or the fixed Runtime config directory', async () => {
|
|
const temporaryDirectory = await mkdtemp(
|
|
join(tmpdir(), 'goodbuddy-runtime-config-')
|
|
)
|
|
temporaryDirectories.push(temporaryDirectory)
|
|
const configPath = join(temporaryDirectory, 'config.yaml')
|
|
await writeFile(configPath, 'name: Test', 'utf8')
|
|
const getPublicSettings = vi.fn(async () => ({
|
|
opencodeConfigPath: '',
|
|
continueConfigPath: process.execPath,
|
|
configured: {
|
|
opencodeConfigPath: '',
|
|
continueConfigPath: configPath
|
|
}
|
|
}))
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{ getPublicSettings } as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const canonicalConfigPath = await realpath(configPath)
|
|
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(event, {
|
|
runtime: 'continue',
|
|
action: 'open-file'
|
|
})
|
|
expect(electronMocks.openPath).toHaveBeenCalledWith(
|
|
canonicalConfigPath
|
|
)
|
|
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(event, {
|
|
runtime: 'continue',
|
|
action: 'show-file'
|
|
})
|
|
expect(electronMocks.showItemInFolder).toHaveBeenCalledWith(
|
|
canonicalConfigPath
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(event, {
|
|
runtime: 'continue',
|
|
action: 'open-file',
|
|
path: join(temporaryDirectory, 'attacker-controlled.yaml')
|
|
})
|
|
).rejects.toThrow()
|
|
expect(getPublicSettings).toHaveBeenCalledTimes(2)
|
|
|
|
getPublicSettings.mockResolvedValueOnce({
|
|
opencodeConfigPath: '',
|
|
continueConfigPath: configPath,
|
|
configured: {
|
|
opencodeConfigPath: '',
|
|
continueConfigPath: process.execPath
|
|
}
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(event, {
|
|
runtime: 'continue',
|
|
action: 'open-file'
|
|
})
|
|
).rejects.toThrow('Runtime 配置文件类型不支持直接打开')
|
|
expect(electronMocks.openPath).toHaveBeenCalledTimes(1)
|
|
|
|
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME
|
|
process.env.XDG_CONFIG_HOME = temporaryDirectory
|
|
try {
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(event, {
|
|
runtime: 'opencode',
|
|
action: 'open-directory'
|
|
})
|
|
expect(electronMocks.openPath).toHaveBeenLastCalledWith(
|
|
await realpath(join(temporaryDirectory, 'opencode'))
|
|
)
|
|
} finally {
|
|
if (previousXdgConfigHome === undefined) {
|
|
delete process.env.XDG_CONFIG_HOME
|
|
} else {
|
|
process.env.XDG_CONFIG_HOME = previousXdgConfigHome
|
|
}
|
|
}
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeSettingsOpenConfig
|
|
)?.(
|
|
{
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
},
|
|
{
|
|
runtime: 'continue',
|
|
action: 'open-directory'
|
|
}
|
|
)
|
|
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers window controls', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('restricts custom chrome controls to the trusted main window', async () => {
|
|
let maximized = false
|
|
const listeners = new Map<string, () => void>()
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => maximized),
|
|
minimize: vi.fn(),
|
|
maximize: vi.fn(() => {
|
|
maximized = true
|
|
}),
|
|
unmaximize: vi.fn(() => {
|
|
maximized = false
|
|
}),
|
|
close: vi.fn(),
|
|
on: vi.fn((name: string, listener: () => void) => {
|
|
listeners.set(name, listener)
|
|
}),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
{
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
} as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
electronMocks.handlers.get(ipcChannels.windowMinimize)?.(event)
|
|
electronMocks.handlers.get(
|
|
ipcChannels.windowToggleMaximize
|
|
)?.(event)
|
|
listeners.get('maximize')?.()
|
|
electronMocks.handlers.get(ipcChannels.windowClose)?.(event)
|
|
|
|
expect(window.minimize).toHaveBeenCalledOnce()
|
|
expect(window.maximize).toHaveBeenCalledOnce()
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.windowMaximizedChanged,
|
|
true
|
|
)
|
|
expect(window.close).toHaveBeenCalledOnce()
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers
|
|
.get(ipcChannels.windowIsMaximized)
|
|
?.({
|
|
sender: {},
|
|
senderFrame: webContents.mainFrame
|
|
}))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
|
|
await dispose()
|
|
expect(window.removeListener).toHaveBeenCalledWith(
|
|
'maximize',
|
|
listeners.get('maximize')
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers workspace files', () => {
|
|
const temporaryDirectories: string[] = []
|
|
|
|
afterEach(async () => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) =>
|
|
rm(directory, { recursive: true, force: true })
|
|
)
|
|
)
|
|
})
|
|
|
|
it('resolves the project root and validates file requests', async () => {
|
|
const rootPath = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-files-'))
|
|
temporaryDirectories.push(rootPath)
|
|
await writeFile(join(rootPath, 'README.md'), '# GoodBuddy\n')
|
|
const projectId = '00000000-0000-4000-8000-000000000101'
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
getProject: vi.fn(() => ({ id: projectId, rootPath }))
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{} as never,
|
|
vi.fn(async () => {}),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
onChanged: vi.fn(),
|
|
get: vi.fn(async () => {
|
|
throw new Error('settings unavailable')
|
|
})
|
|
} as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
const list = await electronMocks.handlers.get(
|
|
ipcChannels.workspaceDirectoryList
|
|
)?.(event, { projectId, path: '' })
|
|
const preview = await electronMocks.handlers.get(
|
|
ipcChannels.workspaceFileRead
|
|
)?.(event, { projectId, path: 'README.md' })
|
|
|
|
expect(list).toMatchObject({
|
|
entries: [
|
|
{ name: 'README.md', path: 'README.md', type: 'file' }
|
|
]
|
|
})
|
|
expect(preview).toMatchObject({
|
|
path: 'README.md',
|
|
content: '# GoodBuddy\n',
|
|
mimeType: 'text/markdown'
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.(event, {
|
|
projectId,
|
|
path: '../outside.txt'
|
|
})
|
|
).rejects.toThrow('路径必须是工作区内的相对路径')
|
|
expect(assistantDatabase.getProject).toHaveBeenCalledWith(projectId)
|
|
|
|
const source = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-import-'))
|
|
temporaryDirectories.push(source)
|
|
await mkdir(join(rootPath, 'selected'))
|
|
await writeFile(join(source, 'binary.png'), Buffer.from([0, 255, 128, 1]))
|
|
const importFiles = electronMocks.handlers.get(ipcChannels.workspaceImportFiles)!
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: true, filePaths: [] })
|
|
await expect(importFiles(event, { projectId, path: 'selected' })).resolves.toEqual({ imported: [], failed: [] })
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: [join(source, 'binary.png')] })
|
|
await expect(importFiles(event, { projectId, path: 'selected' })).resolves.toEqual({ imported: ['selected/binary.png'], failed: [] })
|
|
expect(await readFile(join(rootPath, 'selected/binary.png'))).toEqual(Buffer.from([0, 255, 128, 1]))
|
|
await expect(importFiles(event, { projectId, path: '../outside' })).rejects.toThrow()
|
|
|
|
await dispose()
|
|
})
|
|
|
|
it('fails remote workspace requests before filesystem, Git, or shell access', async () => {
|
|
const projectId = '00000000-0000-4000-8000-000000000102'
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
getProject: vi.fn(() => ({
|
|
id: projectId,
|
|
name: 'Remote',
|
|
description: '',
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000202',
|
|
remoteRootPath: '/srv/project'
|
|
},
|
|
kind: 'user',
|
|
status: 'active',
|
|
createdAt: '2026-08-01T00:00:00.000Z',
|
|
updatedAt: '2026-08-01T00:00:00.000Z'
|
|
}))
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{} as never,
|
|
vi.fn(async () => {}),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
onChanged: vi.fn(),
|
|
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
|
|
} as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
for (const [channel, input] of [
|
|
[ipcChannels.workspaceChangesGet, projectId],
|
|
[ipcChannels.workspaceFileDiff, { projectId, path: 'deleted.txt' }],
|
|
[
|
|
ipcChannels.workspaceDirectoryList,
|
|
{ projectId, path: '' }
|
|
],
|
|
[
|
|
ipcChannels.workspaceFileRead,
|
|
{ projectId, path: 'README.md' }
|
|
],
|
|
[
|
|
ipcChannels.workspacePathOpen,
|
|
{ projectId, path: 'README.md', type: 'file' }
|
|
]
|
|
] as const) {
|
|
await expect(
|
|
electronMocks.handlers.get(channel)?.(event, input)
|
|
).rejects.toThrow('远程执行空间尚不可用')
|
|
}
|
|
expect(electronMocks.openPath).not.toHaveBeenCalled()
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers token usage', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates execution statistics scope and sender before reading a timing snapshot', async () => {
|
|
const summary = { durationMs: 5000, runningCount: 0, incomplete: true, asOf: 10000, taskDurations: [{ id: 'task', durationMs: 5000, runningCount: 0, incomplete: true }] }
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
getExecutionStats: vi.fn(() => summary)
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn()
|
|
}
|
|
const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() }
|
|
const dispose = registerIpcHandlers(
|
|
window as never, { capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space', {} as never, {} as never,
|
|
{ clear: vi.fn() } as never, {} as never, assistantDatabase as never,
|
|
{} as never, vi.fn(async () => {})
|
|
)
|
|
try {
|
|
const handler = electronMocks.handlers.get(ipcChannels.tasksExecutionStats)!
|
|
const event = { sender: webContents, senderFrame: webContents.mainFrame }
|
|
const id = '00000000-0000-4000-8000-000000000301'
|
|
for (const scope of [{ conversationId: id }, { projectId: id }]) {
|
|
expect(await handler(event, scope)).toEqual(summary)
|
|
expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope)
|
|
}
|
|
await expect(Promise.resolve().then(() => handler(event, {}))).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() => handler(event, { projectId: id, conversationId: id }))).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id }))).rejects.toThrow()
|
|
expect(assistantDatabase.getExecutionStats).toHaveBeenCalledTimes(2)
|
|
} finally {
|
|
await dispose()
|
|
}
|
|
})
|
|
|
|
it('returns the database token summary to a trusted renderer', async () => {
|
|
const summary = {
|
|
totals: {
|
|
callCount: 2,
|
|
input: 120,
|
|
output: 30,
|
|
cacheRead: 10,
|
|
cacheWrite: 5,
|
|
totalTokens: 165
|
|
},
|
|
records: []
|
|
}
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
getTokenUsageSummary: vi.fn(() => summary)
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: {
|
|
url: 'file:///goodbuddy/index.html'
|
|
},
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
|
|
const handler = electronMocks.handlers.get(
|
|
ipcChannels.tokenUsageSummary
|
|
)
|
|
expect(handler).toBeDefined()
|
|
expect(
|
|
await handler?.({
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
})
|
|
).toBe(summary)
|
|
expect(assistantDatabase.getTokenUsageSummary).toHaveBeenCalledOnce()
|
|
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers local conversation persistence', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it.each(['current', 'profile', 'image', 'missing'] as const)(
|
|
'routes direct model manual compaction using %s selection', async (source) => {
|
|
const profile = {
|
|
id: crypto.randomUUID(), name: 'Selected', baseUrl: 'https://models.example',
|
|
modelName: 'selected-text', protocol: 'anthropic-messages', authentication: 'none'
|
|
}
|
|
const fallback = { ...profile, id: crypto.randomUUID(), modelName: 'default-text' }
|
|
const summary = { ...profile, id: crypto.randomUUID(), modelName: 'summary-text' }
|
|
const selection = { provider: 'model' as const, profileId: source === 'missing' ? crypto.randomUUID() : profile.id }
|
|
const settings = {
|
|
provider: 'model', workspacePath: 'C:\\Workspace', defaultModelProfileId: fallback.id,
|
|
modelProfiles: [fallback, { ...profile, protocol: source === 'image' ? 'openai-images-generations' : profile.protocol }, summary],
|
|
continueModelProfile: fallback,
|
|
contextCompression: { enabled: false, modelSource: source === 'profile' ? { kind: 'profile', profileId: summary.id } : { kind: 'current' } }
|
|
}
|
|
const messages = [
|
|
{ id: crypto.randomUUID(), role: 'user', content: 'earlier question', state: 'complete' },
|
|
{ id: crypto.randomUUID(), role: 'assistant', content: 'earlier answer', state: 'complete' }
|
|
]
|
|
const conversationId = crypto.randomUUID()
|
|
const database = {
|
|
queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
// Stored conversations hold a layer; the renderer sends the resolved selection.
|
|
getConversation: vi.fn(() => ({ id: conversationId, runtimeSelection: {
|
|
provider: 'model', model: { kind: 'profile', profileId: selection.profileId }
|
|
}, messages })),
|
|
createTask: vi.fn(), updateTaskStatus: vi.fn()
|
|
}
|
|
const compactor = {
|
|
compactConversation: vi.fn(async () => ({ result: {
|
|
provider: 'model', strategy: 'goodbuddy-summary', compacted: true, detail: 'Compacted'
|
|
} })),
|
|
dispose: vi.fn(async () => {})
|
|
}
|
|
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(compactor)
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn()
|
|
}
|
|
const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() }
|
|
const dispose = registerIpcHandlers(
|
|
window as never, {} as never, 'CommandOrControl+Shift+Space',
|
|
{ getResolvedSettings: vi.fn(async () => settings) } as never,
|
|
{} as never, { clear: vi.fn() } as never, {} as never, database as never,
|
|
{} as never, vi.fn(async () => {})
|
|
)
|
|
try {
|
|
const result = electronMocks.handlers.get(ipcChannels.agentCompactConversation)?.(
|
|
{ sender: webContents, senderFrame: webContents.mainFrame },
|
|
{ requestId: crypto.randomUUID(), conversationId, runtimeSelection: selection,
|
|
history: messages.map(({ role, content }) => ({ role, content })),
|
|
historyMessageIds: messages.map(({ id }) => id) }
|
|
)
|
|
if (source === 'image' || source === 'missing') {
|
|
await expect(result).rejects.toThrow()
|
|
expect(runtimeFactoryMocks.createModelProfileRuntime).not.toHaveBeenCalled()
|
|
} else {
|
|
await expect(result).resolves.toMatchObject({ provider: 'model', compacted: true })
|
|
expect(runtimeFactoryMocks.createModelProfileRuntime).toHaveBeenCalledWith(
|
|
'C:\\Workspace', expect.objectContaining({ defaultModelProfileId: profile.id }),
|
|
source === 'profile' ? summary : profile
|
|
)
|
|
expect(compactor.dispose).toHaveBeenCalledOnce()
|
|
expect(database.updateTaskStatus).toHaveBeenCalledWith(expect.any(String), 'completed')
|
|
}
|
|
} finally {
|
|
await dispose()
|
|
}
|
|
}
|
|
)
|
|
|
|
it('pins and unpins without attachment reconciliation or import checks', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-pin-ipc-'))
|
|
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
|
|
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
|
const assets = new ConversationAttachmentStorage(root, results)
|
|
let dispose: ReturnType<typeof registerIpcHandlers> | undefined
|
|
try {
|
|
database.initialize(root)
|
|
const conversationId = '00000000-0000-4000-8000-000000000301'
|
|
const messageId = '00000000-0000-4000-8000-000000000302'
|
|
database.saveLocalConversations([{
|
|
header: { id: conversationId, title: 'Pin regression', updatedAt: 1 },
|
|
messages: [{ id: messageId, role: 'user', content: 'Keep this message', createdAt: 1, state: 'complete' }]
|
|
}])
|
|
const assetId = assets.beginParsing(conversationId, 'pending.pdf', Buffer.from('original'))
|
|
assets.reference(conversationId, 'draft', conversationId, [assetId])
|
|
assets.reference(conversationId, 'message', messageId, [assetId])
|
|
const before = database.getConversation(conversationId)
|
|
const reconcile = vi.spyOn(assets, 'reconcile')
|
|
const collect = vi.spyOn(assets, 'collect')
|
|
const hasOwner = vi.spyOn(database, 'hasAttachmentOwner')
|
|
const contextManager = {
|
|
assets,
|
|
activeContextIds: vi.fn(() => []),
|
|
cancelUnavailableImport: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const webContents = {
|
|
on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents, isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(), removeListener: vi.fn()
|
|
}
|
|
dispose = registerIpcHandlers(
|
|
window as never, { capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space', {} as never, {} as never,
|
|
contextManager as never, {} as never, database,
|
|
{} as never, vi.fn(async () => {})
|
|
)
|
|
const event = { sender: webContents, senderFrame: webContents.mainFrame }
|
|
const pin = electronMocks.handlers.get(ipcChannels.conversationsSetPinned)!
|
|
expect(pin).toBeTypeOf('function')
|
|
webContents.send.mockClear()
|
|
for (const pinned of [true, true, false]) {
|
|
expect(await pin(event, { conversationId, pinned })).toBeUndefined()
|
|
expect(database.getConversation(conversationId)).toEqual({ ...before, pinned })
|
|
}
|
|
expect(webContents.send.mock.calls).toEqual([
|
|
[ipcChannels.conversationsChanged],
|
|
[ipcChannels.conversationsChanged],
|
|
[ipcChannels.conversationsChanged]
|
|
])
|
|
expect({
|
|
reconciliations: reconcile.mock.calls.length,
|
|
ownerChecks: hasOwner.mock.calls.length,
|
|
collections: collect.mock.calls.length,
|
|
activeContextReads: contextManager.activeContextIds.mock.calls.length,
|
|
importChecks: contextManager.cancelUnavailableImport.mock.calls.length
|
|
}).toEqual({ reconciliations: 0, ownerChecks: 0, collections: 0, activeContextReads: 0, importChecks: 0 })
|
|
expect(assets.draft(conversationId).map((asset) => asset.id)).toEqual([assetId])
|
|
expect(assets.get(assetId).parsingState).toBe('parsing')
|
|
expect(assets.original(assetId).data).toEqual(Buffer.from('original'))
|
|
|
|
webContents.send.mockClear()
|
|
await expect(Promise.resolve().then(() => pin(event, { conversationId, pinned: 'yes' }))).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() => pin({ ...event, sender: {} }, { conversationId, pinned: true }))).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() => pin(event, {
|
|
conversationId: '00000000-0000-4000-8000-000000000399', pinned: true
|
|
}))).rejects.toThrow()
|
|
expect(webContents.send).not.toHaveBeenCalled()
|
|
expect(database.getConversation(conversationId)).toEqual(before)
|
|
|
|
const setStoryGraph = electronMocks.handlers.get(ipcChannels.conversationsSetStoryGraph)!
|
|
expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(true)
|
|
for (const enabled of [false, true]) {
|
|
await setStoryGraph(event, { conversationId, enabled })
|
|
expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(enabled)
|
|
expect(database.getConversation(conversationId)).toEqual({ ...before, storyGraphEnabled: enabled })
|
|
}
|
|
await expect(Promise.resolve().then(() => setStoryGraph(event, { conversationId, enabled: 'false' }))).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() => setStoryGraph({ ...event, sender: {} }, { conversationId, enabled: false }))).rejects.toThrow()
|
|
|
|
expect(await electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)).toBe(true)
|
|
expect(collect).toHaveBeenCalledOnce()
|
|
expect(contextManager.cancelUnavailableImport).toHaveBeenCalledOnce()
|
|
expect(assets.has(assetId)).toBe(false)
|
|
} finally {
|
|
await dispose?.()
|
|
assets.close()
|
|
await results.close()
|
|
database.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('validates full handles across child adoption, stale/current saves and branching, and releases deleted history', async () => {
|
|
const parent = join(process.cwd(), 'temp', 'output-history-ipc')
|
|
await mkdir(parent, { recursive: true })
|
|
const root = await mkdtemp(join(parent, 'fixture-'))
|
|
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
|
|
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
|
|
const assets = new ConversationAttachmentStorage(root, results)
|
|
let dispose: ReturnType<typeof registerIpcHandlers> | undefined
|
|
try {
|
|
await database.initialize(root)
|
|
const conversationId = crypto.randomUUID(), messageId = crypto.randomUUID()
|
|
const childTaskId = crypto.randomUUID()
|
|
const outputId = crypto.randomUUID(), handle = `process:${outputId}`
|
|
const text = '\u4e2d\ud83d\ude00'.repeat(20000) + '-complete-tail'
|
|
const bytes = Buffer.from(text)
|
|
assets.outputCreate(childTaskId, handle)
|
|
for (let offset = 0; offset < bytes.length; offset += 65536) await assets.outputAppend(childTaskId, handle, bytes.subarray(offset, offset + 65536))
|
|
await assets.outputFinish(childTaskId, handle, bytes.length)
|
|
const reference = { handle, totalBytes: bytes.length, nextCursor: 0 }
|
|
const childHandle = `subagent:${crypto.randomUUID()}`, progressHandle = `rg:${crypto.randomUUID()}`
|
|
for (const outputHandle of [childHandle, progressHandle]) {
|
|
assets.outputCreate(childTaskId, outputHandle)
|
|
await assets.outputAppend(childTaskId, outputHandle, Buffer.from('child output'))
|
|
await assets.outputFinish(childTaskId, outputHandle, 12)
|
|
}
|
|
const childReference = { handle: childHandle, totalBytes: 12, nextCursor: 0 }
|
|
const progressReference = { ...childReference, handle: progressHandle }
|
|
const handles = [handle, childHandle, progressHandle]
|
|
const tool = { callId: 'call', name: 'Process', state: 'completed' as const, summary: 'Output', output: 'preview', outputReferences: [reference] }
|
|
const batch = [{ header: { id: conversationId, title: 'Output history', updatedAt: 1 }, messages: [{
|
|
id: messageId, role: 'assistant' as const, content: '', state: 'complete' as const, createdAt: 1,
|
|
tools: [tool], blocks: [{ id: crypto.randomUUID(), type: 'tool' as const, tool }],
|
|
subagents: [{ childTaskId, actor: { kind: 'direct-model' as const, label: '编程 Subagent' as const }, routingMode: 'native' as const,
|
|
state: 'completed' as const, outputReference: childReference,
|
|
progress: [{ id: crypto.randomUUID(), type: 'tool' as const, tool: { ...tool, outputReferences: [progressReference] } }] }]
|
|
}] }]
|
|
database.saveLocalConversations(batch)
|
|
for (const outputHandle of handles) assets.outputAdopt(childTaskId, outputHandle, conversationId, 'message', messageId)
|
|
const referenced = vi.spyOn(assets, 'reference')
|
|
const contextManager = { assets, clear: vi.fn(), activeContextIds: () => [], cancelUnavailableImport: vi.fn() }
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(), mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: () => 'file:///goodbuddy/index.html', send: vi.fn() }
|
|
const window = { webContents, isDestroyed: () => false, on: vi.fn(), removeListener: vi.fn() }
|
|
dispose = registerIpcHandlers(window as never, { capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space', {} as never, {} as never, contextManager as never,
|
|
{} as never, database, {} as never, vi.fn(async () => {}))
|
|
const event = { sender: webContents, senderFrame: webContents.mainFrame }
|
|
const save = electronMocks.handlers.get(ipcChannels.conversationsSaveLocal)!
|
|
await save(event, [{ ...batch[0], messages: [{ ...batch[0]!.messages[0], tools: [], blocks: [], subagents: [] }] }])
|
|
expect(referenced).toHaveBeenLastCalledWith(conversationId, 'message', messageId, [], [])
|
|
for (const outputHandle of handles) assets.outputRelease(childTaskId, outputHandle)
|
|
assets.collect()
|
|
const read = electronMocks.handlers.get(ipcChannels.contextReadOutput)!
|
|
for (const outputHandle of handles) expect(await read(event, { conversationId, handle: outputHandle })).toHaveProperty('content')
|
|
await save(event, batch)
|
|
expect(referenced).toHaveBeenLastCalledWith(conversationId, 'message', messageId,
|
|
handles.map(outputHandle => outputHandle.split(':')[1]), handles)
|
|
const saved = database.getConversation(conversationId)
|
|
const unfinishedHandle = `process:${crypto.randomUUID()}`
|
|
assets.outputCreate(childTaskId, unfinishedHandle)
|
|
for (const [invalidHandle, error] of [
|
|
[`process:${crypto.randomUUID()}`, '附件资源不存在'],
|
|
[unfinishedHandle, 'Full output is missing or unfinished'],
|
|
[`rg:${outputId}`, 'Full output is missing or unfinished']
|
|
] as const) {
|
|
await expect(save(event, [{ ...batch[0], messages: [{ ...batch[0]!.messages[0], content: 'must not persist',
|
|
tools: [{ ...tool, outputReferences: [reference, { ...reference, handle: invalidHandle }] }]
|
|
}] }])).rejects.toThrow(error)
|
|
expect(database.getConversation(conversationId)).toEqual(saved)
|
|
}
|
|
assets.outputRelease(childTaskId, unfinishedHandle)
|
|
let restored = '', cursor = 0
|
|
while (cursor < bytes.length) {
|
|
const page = await read(event, { conversationId, handle, cursor }) as import('../shared/conversation-output').ConversationOutputPage
|
|
expect(Buffer.byteLength(page.content)).toBeLessThanOrEqual(32768)
|
|
expect(page.nextCursor).toBeGreaterThan(cursor)
|
|
restored += page.content
|
|
cursor = page.nextCursor
|
|
}
|
|
expect(restored).toBe(text)
|
|
expect(await read(event, { conversationId, handle, limitBytes: 1 })).toMatchObject({ content: '\u4e2d', nextCursor: 3 })
|
|
await expect(read(event, { conversationId, handle, cursor: 1 })).rejects.toThrow('UTF-8')
|
|
await expect(read(event, { conversationId, handle, limitBytes: 32769 })).rejects.toThrow()
|
|
await expect(read(event, { conversationId: crypto.randomUUID(), handle })).rejects.toThrow('owner')
|
|
await expect(read({ ...event, sender: {} }, { conversationId, handle })).rejects.toThrow()
|
|
const branch = await electronMocks.handlers.get(ipcChannels.conversationsBranchLocal)!(event, { sourceConversationId: conversationId, title: 'Branch' }) as ConversationSnapshot
|
|
expect(branch.messages[0]?.tools?.[0]?.outputReferences).toEqual([reference])
|
|
expect(branch.messages[0]?.subagents).toBeUndefined()
|
|
expect(referenced).toHaveBeenLastCalledWith(branch.id, 'message', branch.messages[0]!.id,
|
|
[outputId], [handle])
|
|
await electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)
|
|
await save(event, [{ header: { id: branch.id, title: 'Header-only update', updatedAt: Date.now() }, messages: [] }])
|
|
expect(await read(event, { conversationId: branch.id, handle, cursor: bytes.length - 4 })).toMatchObject({ content: 'tail', eof: true })
|
|
for (const outputHandle of [childHandle, progressHandle]) expect(assets.has(outputHandle.split(':')[1]!)).toBe(false)
|
|
await electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, branch.id)
|
|
await expect(read(event, { conversationId: branch.id, handle })).rejects.toThrow()
|
|
for (const outputHandle of handles) expect(assets.has(outputHandle.split(':')[1]!)).toBe(false)
|
|
} finally {
|
|
await dispose?.()
|
|
assets.close()
|
|
await results.close()
|
|
database.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('validates and forwards incremental saves, branches, and explicit deletions', async () => {
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000301'
|
|
const queuedAttachmentId =
|
|
'00000000-0000-4000-8000-000000000303'
|
|
const queuedItem = {
|
|
id: '00000000-0000-4000-8000-000000000304',
|
|
conversationId,
|
|
source: 'user' as const,
|
|
label: '待删除消息',
|
|
createdAt: '2026-08-20T09:01:00.000Z'
|
|
}
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => [queuedItem]),
|
|
listPendingConversationQueueIds: vi.fn(() => [conversationId]),
|
|
getConversationUserQueuePayloadJson: vi.fn(() =>
|
|
JSON.stringify({
|
|
input: {
|
|
conversationId,
|
|
runtimeSelection: { provider: 'model' },
|
|
includeMemoryContext: true,
|
|
prompt: queuedItem.label,
|
|
attachments: [
|
|
{
|
|
id: queuedAttachmentId,
|
|
name: 'queued.txt',
|
|
size: 6,
|
|
preview: 'queued',
|
|
kind: 'text'
|
|
}
|
|
],
|
|
knowledgeLibraryIds: [],
|
|
knowledgeRetrievalMode: 'auto'
|
|
},
|
|
serializedContexts: '[]'
|
|
})
|
|
),
|
|
saveLocalConversations: vi.fn(),
|
|
branchLocalConversation: vi.fn((input) => ({
|
|
id: '00000000-0000-4000-8000-000000000305',
|
|
branch: {
|
|
sourceConversationId: input.sourceConversationId,
|
|
sourceTitle: '增量会话'
|
|
},
|
|
title: input.title,
|
|
updatedAt: 2,
|
|
messages: []
|
|
})),
|
|
deleteLocalConversation: vi.fn(() => true)
|
|
}
|
|
const contextManager = {
|
|
clear: vi.fn(),
|
|
remove: vi.fn()
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: {
|
|
url: 'file:///goodbuddy/index.html'
|
|
},
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
contextManager as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
const messageId = '00000000-0000-4000-8000-000000000302'
|
|
const batch = [
|
|
{
|
|
header: {
|
|
id: conversationId,
|
|
title: '增量会话',
|
|
updatedAt: 1
|
|
},
|
|
messages: [
|
|
{
|
|
id: messageId,
|
|
role: 'assistant' as const,
|
|
content: '增量内容',
|
|
createdAt: 1,
|
|
state: 'streaming' as const,
|
|
tools: [
|
|
{
|
|
callId: 'remote-tool-call',
|
|
name: '远程工具',
|
|
state: 'completed' as const,
|
|
summary: '完整远程工具结果',
|
|
output: '远程输出'.repeat(10_000)
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
|
|
expect(
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.conversationsSaveLocal
|
|
)?.(event, batch)
|
|
).toBeUndefined()
|
|
expect(
|
|
assistantDatabase.saveLocalConversations
|
|
).toHaveBeenCalledWith(batch)
|
|
const branchInput = {
|
|
sourceConversationId: conversationId,
|
|
title: '增量会话 · 分支'
|
|
}
|
|
expect(
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.conversationsBranchLocal
|
|
)?.(event, branchInput)
|
|
).toMatchObject({
|
|
branch: { sourceConversationId: conversationId },
|
|
title: branchInput.title
|
|
})
|
|
expect(
|
|
assistantDatabase.branchLocalConversation
|
|
).toHaveBeenCalledWith(branchInput)
|
|
expect(
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.conversationsDeleteLocal
|
|
)?.(event, conversationId)
|
|
).toBe(true)
|
|
expect(
|
|
assistantDatabase.deleteLocalConversation
|
|
).toHaveBeenCalledWith(conversationId)
|
|
expect(contextManager.remove).toHaveBeenCalledWith(
|
|
queuedAttachmentId
|
|
)
|
|
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationsSaveLocal
|
|
)?.(event, [
|
|
{
|
|
...batch[0],
|
|
header: {
|
|
...batch[0]!.header,
|
|
remote: {
|
|
channel: 'weixin',
|
|
accountDisplay: 'remote',
|
|
conversationType: 'direct'
|
|
}
|
|
}
|
|
}
|
|
]))).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationsBranchLocal
|
|
)?.(event, {
|
|
sourceConversationId: 'not-a-uuid',
|
|
title: '无效分支'
|
|
}))).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationsDeleteLocal
|
|
)?.(event, 'not-a-uuid'))).rejects.toThrow()
|
|
|
|
await dispose()
|
|
})
|
|
|
|
it('waits for the renderer persistence acknowledgement before removing handlers', async () => {
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => [])
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: {
|
|
url: 'file:///goodbuddy/index.html'
|
|
},
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{} as never,
|
|
vi.fn(async () => {})
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
electronMocks.handlers.get(
|
|
ipcChannels.appRendererPersistenceReady
|
|
)?.(event)
|
|
|
|
const disposal = dispose()
|
|
await vi.waitFor(() =>
|
|
expect(webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.appRendererPersistenceRequest,
|
|
expect.any(String)
|
|
)
|
|
)
|
|
expect(
|
|
await electronMocks.handlers.has(ipcChannels.conversationsSaveLocal)
|
|
).toBe(true)
|
|
const requestId = webContents.send.mock.calls.find(
|
|
([channel]) =>
|
|
channel === ipcChannels.appRendererPersistenceRequest
|
|
)?.[1]
|
|
expect(requestId).toEqual(expect.any(String))
|
|
electronMocks.handlers.get(
|
|
ipcChannels.appRendererPersistenceComplete
|
|
)?.(event, requestId)
|
|
|
|
await disposal
|
|
expect(
|
|
await electronMocks.handlers.has(ipcChannels.conversationsSaveLocal)
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers Runtime customization', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('validates customization, native inventory, and trusted manual compaction', async () => {
|
|
const projectId = '00000000-0000-4000-8000-000000000601'
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000602'
|
|
const requestId = '00000000-0000-4000-8000-000000000603'
|
|
const messageIds = [
|
|
'00000000-0000-4000-8000-000000000604',
|
|
'00000000-0000-4000-8000-000000000605'
|
|
]
|
|
const customization = {
|
|
opencode: { defaultAgent: 'planner' },
|
|
continue: { presets: [] }
|
|
}
|
|
const snapshot = {
|
|
provider: 'opencode' as const,
|
|
available: true,
|
|
inventoryStatus: 'available' as const,
|
|
detail: 'OpenCode native capabilities are ready',
|
|
agents: [
|
|
{
|
|
id: 'planner',
|
|
name: 'Planner',
|
|
mode: 'primary' as const,
|
|
native: true,
|
|
hidden: false
|
|
}
|
|
],
|
|
tools: [
|
|
{
|
|
id: 'edit',
|
|
name: 'edit',
|
|
kind: 'write' as const,
|
|
source: 'runtime' as const,
|
|
}
|
|
],
|
|
toolsSupported: true,
|
|
commands: [],
|
|
lsp: [],
|
|
formatters: [],
|
|
mcpServers: [],
|
|
skills: [],
|
|
rules: [],
|
|
prompts: [],
|
|
resources: [],
|
|
resourcesSupported: true,
|
|
context: {
|
|
strategy: 'native' as const,
|
|
manualCompact: true,
|
|
detail: 'OpenCode manages native context'
|
|
}
|
|
}
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isFocused: vi.fn(() => true),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const settingsStore = {
|
|
getRuntimeCustomization: vi.fn(async () => customization),
|
|
updateRuntimeCustomization: vi.fn(
|
|
async (value: unknown) => value
|
|
),
|
|
getResolvedSettings: vi.fn(async () => ({
|
|
provider: 'opencode',
|
|
modelProfiles: [],
|
|
opencodeBaseUrl: '',
|
|
workspacePath: 'C:\\DefaultWorkspace'
|
|
}))
|
|
}
|
|
const messages = [
|
|
{
|
|
id: messageIds[0]!,
|
|
role: 'user' as const,
|
|
content: 'First turn',
|
|
state: 'complete' as const
|
|
},
|
|
{
|
|
id: messageIds[1]!,
|
|
role: 'assistant' as const,
|
|
content: 'Second turn',
|
|
state: 'complete' as const
|
|
}
|
|
]
|
|
let persistedRuntimeSelection:
|
|
| { provider: 'opencode' }
|
|
| undefined = { provider: 'opencode' }
|
|
const assistantDatabase = {
|
|
queueDueSchedules: vi.fn(() => []),
|
|
listConversationQueueItems: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
getProject: vi.fn(() => ({
|
|
id: projectId,
|
|
rootPath: 'C:\\ProjectWorkspace',
|
|
runtimeSelection: undefined as { provider: 'opencode' } | undefined
|
|
})),
|
|
getConversation: vi.fn(() => ({
|
|
id: conversationId,
|
|
projectId,
|
|
runtimeSelection: persistedRuntimeSelection,
|
|
title: 'Runtime conversation',
|
|
updatedAt: Date.now(),
|
|
messages
|
|
})),
|
|
createTask: vi.fn(),
|
|
updateTaskStatus: vi.fn(),
|
|
upsertModelUsageCall: vi.fn()
|
|
}
|
|
let releaseFirstCompaction: (() => void) | undefined
|
|
const firstCompactionBlocked = new Promise<void>((resolve) => {
|
|
releaseFirstCompaction = resolve
|
|
})
|
|
const compactionOutcome = {
|
|
result: {
|
|
provider: 'opencode' as const,
|
|
strategy: 'native' as const,
|
|
compacted: true,
|
|
detail: 'OpenCode compacted the conversation'
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getNativeSnapshot: vi.fn(async () => snapshot),
|
|
compactConversation: vi
|
|
.fn()
|
|
.mockImplementationOnce(async () => {
|
|
await firstCompactionBlocked
|
|
return compactionOutcome
|
|
})
|
|
.mockResolvedValue(compactionOutcome)
|
|
}
|
|
const onRuntimeSettingsChanged = vi.fn(async () => undefined)
|
|
const contextManager = { clear: vi.fn() }
|
|
const dispose = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
settingsStore as never,
|
|
{} as never,
|
|
contextManager as never,
|
|
{} as never,
|
|
assistantDatabase as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
selectedRuntimes as never
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
const settingsChannels = [
|
|
ipcChannels.runtimeSettingsGet,
|
|
ipcChannels.runtimeCustomizationGet,
|
|
ipcChannels.runtimeCustomizationUpdate,
|
|
ipcChannels.runtimeNativeSnapshot,
|
|
ipcChannels.runtimeSettingsUpdate,
|
|
ipcChannels.runtimeSettingsSelectWorkspace,
|
|
ipcChannels.runtimeSettingsDetect,
|
|
ipcChannels.runtimeSettingsSelectFile,
|
|
ipcChannels.runtimeSettingsOpenConfig,
|
|
ipcChannels.runtimeSettingsTestModel,
|
|
ipcChannels.runtimeSettingsTest
|
|
]
|
|
const registeredChannels = [...electronMocks.handlers.keys()]
|
|
const settingsStart = registeredChannels.indexOf(settingsChannels[0]!)
|
|
expect(registeredChannels.slice(settingsStart - 1, settingsStart + 12)).toEqual([
|
|
ipcChannels.agentCompactConversation,
|
|
...settingsChannels,
|
|
ipcChannels.sshHostsGet
|
|
])
|
|
for (const channel of settingsChannels) {
|
|
await expect(Promise.resolve().then(() =>
|
|
electronMocks.handlers.get(channel)?.({ ...event, sender: {} }, {})
|
|
)).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
}
|
|
expect(settingsStore.getRuntimeCustomization).not.toHaveBeenCalled()
|
|
expect(settingsStore.updateRuntimeCustomization).not.toHaveBeenCalled()
|
|
expect(selectedRuntimes.getNativeSnapshot).not.toHaveBeenCalled()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationGet
|
|
)?.(event)
|
|
).resolves.toEqual(customization)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationUpdate
|
|
)?.(event, customization)
|
|
).resolves.toEqual(customization)
|
|
expect(
|
|
settingsStore.updateRuntimeCustomization
|
|
).toHaveBeenCalledWith(customization)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
const activationError = new Error('customization activation failed')
|
|
onRuntimeSettingsChanged
|
|
.mockRejectedValueOnce(activationError)
|
|
.mockResolvedValueOnce(undefined)
|
|
const failedCustomization = {
|
|
...customization,
|
|
opencode: { defaultAgent: 'candidate-agent' }
|
|
}
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationUpdate
|
|
)?.(event, failedCustomization)
|
|
).rejects.toBe(activationError)
|
|
expect(
|
|
settingsStore.updateRuntimeCustomization
|
|
).toHaveBeenNthCalledWith(2, failedCustomization)
|
|
expect(
|
|
settingsStore.updateRuntimeCustomization
|
|
).toHaveBeenNthCalledWith(3, customization)
|
|
expect(onRuntimeSettingsChanged).toHaveBeenCalledTimes(3)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationUpdate
|
|
)?.(event, {
|
|
...customization,
|
|
unknown: true
|
|
})
|
|
).rejects.toThrow()
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.runtimeNativeSnapshot
|
|
)?.(event, {
|
|
provider: 'opencode',
|
|
projectId
|
|
})
|
|
).resolves.toEqual(snapshot)
|
|
expect(selectedRuntimes.getNativeSnapshot).toHaveBeenCalledWith(
|
|
{ provider: 'opencode' },
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
})
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.runtimeNativeSnapshot)?.(event, {
|
|
provider: 'opencode'
|
|
})
|
|
).resolves.toEqual(snapshot)
|
|
expect(selectedRuntimes.getNativeSnapshot).toHaveBeenLastCalledWith(
|
|
{ provider: 'opencode' },
|
|
expect.objectContaining({ kind: 'local', rootPath: 'C:\\DefaultWorkspace' })
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.runtimeNativeSnapshot)?.(event, {
|
|
provider: 'opencode', projectId: 'invalid'
|
|
})
|
|
).rejects.toThrow()
|
|
expect(selectedRuntimes.getNativeSnapshot).toHaveBeenCalledTimes(2)
|
|
|
|
const compactInput = {
|
|
requestId,
|
|
conversationId,
|
|
projectId,
|
|
// The renderer sends the resolved selection; OpenCode here uses its own configuration.
|
|
runtimeSelection: { provider: 'opencode' as const, runtimeConfig: true as const },
|
|
history: messages.map(({ role, content }) => ({
|
|
role,
|
|
content
|
|
})),
|
|
historyMessageIds: messageIds
|
|
}
|
|
const firstCompaction = electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, compactInput)
|
|
await vi.waitFor(() =>
|
|
expect(selectedRuntimes.compactConversation).toHaveBeenCalledOnce()
|
|
)
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, compactInput)
|
|
).rejects.toThrow('上下文压缩请求正在执行')
|
|
releaseFirstCompaction?.()
|
|
await expect(firstCompaction).resolves.toEqual({
|
|
provider: 'opencode',
|
|
strategy: 'native',
|
|
compacted: true,
|
|
detail: 'OpenCode compacted the conversation'
|
|
})
|
|
expect(selectedRuntimes.compactConversation).toHaveBeenCalledWith(
|
|
expect.objectContaining(compactInput),
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
}),
|
|
expect.any(AbortSignal)
|
|
)
|
|
expect(assistantDatabase.createTask).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: requestId,
|
|
visible: false
|
|
})
|
|
)
|
|
expect(assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, {
|
|
...compactInput,
|
|
requestId: '00000000-0000-4000-8000-000000000606',
|
|
runtimeSelection: { provider: 'continue' }
|
|
})
|
|
).rejects.toThrow('对话 Runtime 或 Project 已更改')
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, {
|
|
...compactInput,
|
|
requestId: '00000000-0000-4000-8000-000000000607',
|
|
projectId: '00000000-0000-4000-8000-000000000608'
|
|
})
|
|
).rejects.toThrow('对话 Runtime 或 Project 已更改')
|
|
|
|
persistedRuntimeSelection = undefined
|
|
settingsStore.getResolvedSettings.mockResolvedValue({
|
|
provider: 'model',
|
|
modelProfiles: [],
|
|
opencodeBaseUrl: '',
|
|
workspacePath: 'C:\\DefaultWorkspace'
|
|
})
|
|
assistantDatabase.getProject.mockReturnValue({
|
|
id: projectId,
|
|
rootPath: 'C:\\ProjectWorkspace',
|
|
runtimeSelection: { provider: 'opencode' }
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, {
|
|
...compactInput,
|
|
requestId: '00000000-0000-4000-8000-000000000609'
|
|
})
|
|
).resolves.toMatchObject({
|
|
provider: 'opencode',
|
|
compacted: true
|
|
})
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, {
|
|
...compactInput,
|
|
requestId: '00000000-0000-4000-8000-000000000610',
|
|
runtimeSelection: { provider: 'continue' }
|
|
})
|
|
).rejects.toThrow('对话 Runtime 或 Project 已更改')
|
|
|
|
await expect(
|
|
electronMocks.handlers.get(
|
|
ipcChannels.agentCompactConversation
|
|
)?.(event, {
|
|
...compactInput,
|
|
requestId: '00000000-0000-4000-8000-000000000611',
|
|
history: [
|
|
compactInput.history[0],
|
|
{ role: 'assistant', content: 'stale content' }
|
|
]
|
|
})
|
|
).rejects.toThrow('对话历史已更改')
|
|
expect(selectedRuntimes.compactConversation).toHaveBeenCalledTimes(2)
|
|
await dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers agent terminal state', () => {
|
|
type RemoteTaskEventMockInput = {
|
|
taskId: string
|
|
bindingId: string
|
|
operationId: string
|
|
semanticSequence: string
|
|
eventIndex: number
|
|
kind: string
|
|
payload: unknown
|
|
}
|
|
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
function createHarness(
|
|
runtime: Record<string, unknown>,
|
|
onBeforeClearLocalData?: () => Promise<void>,
|
|
subagentService?: Record<string, unknown>,
|
|
smartRoutingEnabled = false,
|
|
selectedRuntimes?: Record<string, unknown>,
|
|
knowledgeServiceOverride?: Record<string, unknown>,
|
|
knowledgeGateway?: Record<string, unknown>,
|
|
magicNotesEnabled = false,
|
|
goodbuddyConfigService?: Record<string, unknown>,
|
|
capabilityServiceOverride?: Record<string, unknown>,
|
|
browserControl?: Record<string, unknown>,
|
|
imageService?: ImageGenerationService,
|
|
imageDatabase?: AssistantDatabase,
|
|
heartbeatEnabled?: boolean,
|
|
obsidianService?: ObsidianService,
|
|
nativeClientCoordinator?: Parameters<typeof registerIpcHandlers>[44],
|
|
nativeTerminalManager?: Parameters<typeof registerIpcHandlers>[40],
|
|
applicationSettingsStore?: ApplicationSettingsStore,
|
|
channelSettingsStore?: ChannelSettingsStore
|
|
) {
|
|
runtime.getStatus ??= vi.fn(async () => ({
|
|
id: runtime.runtimeId ?? 'model', available: true,
|
|
supportsToolExecution: runtime.supportsToolExecution ?? true
|
|
}))
|
|
const appendRemoteTaskEventOnce = vi.fn<
|
|
(input: RemoteTaskEventMockInput) => boolean
|
|
>(() => true)
|
|
const appendRemoteConversationTaskEventOnce =
|
|
vi.fn<AssistantDatabase['appendRemoteConversationTaskEventOnce']>(() => true)
|
|
const assistantDatabase = {
|
|
createTask: vi.fn(),
|
|
appendTaskEvent: vi.fn(),
|
|
appendRemoteTaskEventOnce,
|
|
appendRemoteConversationTaskEventOnce,
|
|
// Batch writes delegate to the per-event mocks by default so tests can
|
|
// assert both per-event payloads and the number of transactions.
|
|
appendRemoteTaskEventsOnce: vi.fn<
|
|
(inputs: RemoteTaskEventMockInput[]) => boolean[]
|
|
>((inputs): boolean[] => inputs.map((input): boolean => appendRemoteTaskEventOnce(input))),
|
|
appendRemoteConversationTaskEventsBatch: vi.fn<AssistantDatabase['appendRemoteConversationTaskEventsBatch']>(
|
|
({ events, ...owner }): boolean[] => events.map((event): boolean =>
|
|
appendRemoteConversationTaskEventOnce({ ...owner, ...event }))
|
|
),
|
|
listConversations: vi.fn<() => ConversationSnapshot[]>(() => []),
|
|
listConversationSummaries: vi.fn<(ids?: string[]) => ConversationSnapshot[]>(() => []),
|
|
listRecoverableRemoteTasks: vi.fn<
|
|
() => Array<{
|
|
taskId: string
|
|
projectId: string
|
|
conversationId: string
|
|
currentUserMessageId: string
|
|
currentAssistantMessageId: string
|
|
instructions: string
|
|
status: 'running' | 'waiting_approval' | 'interrupted'
|
|
}>
|
|
>(() => []),
|
|
getHighestCommittedRemoteTaskEventSequenceForTask: vi.fn<AssistantDatabase['getHighestCommittedRemoteTaskEventSequenceForTask']>(
|
|
() => '0'
|
|
),
|
|
hasRemoteResponseTextAfterToolFailure: vi.fn<AssistantDatabase['hasRemoteResponseTextAfterToolFailure']>(() => false),
|
|
getRemoteTaskActivityStates: vi.fn<AssistantDatabase['getRemoteTaskActivityStates']>(() => ({ tools: new Map(), subagents: new Map() })),
|
|
endRecoverableRemoteTask: vi.fn(),
|
|
recordRemoteTaskQuestionArrival: vi.fn<AssistantDatabase['recordRemoteTaskQuestionArrival']>(),
|
|
recordRemoteTaskQuestionAnswer: vi.fn<AssistantDatabase['recordRemoteTaskQuestionAnswer']>(),
|
|
getTask: vi.fn<AssistantDatabase['getTask']>(),
|
|
updateTaskStatus: vi.fn(),
|
|
createTextArtifact: vi.fn(),
|
|
createImageArtifact: vi.fn(() => ({
|
|
id: '00000000-0000-4000-8000-000000000499',
|
|
title: '生成图片'
|
|
})),
|
|
getArtifact: vi.fn(),
|
|
listProjects: vi.fn<() => AssistantProject[]>(() => [
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000401',
|
|
name: '企业微信',
|
|
description: '企业微信远程消息与受控任务',
|
|
rootPath: 'C:\\ProjectWorkspace',
|
|
executionSpace: {
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
},
|
|
runtimeSelection: {
|
|
provider: 'model',
|
|
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000001' }
|
|
},
|
|
kind: 'channel',
|
|
channel: 'wecom',
|
|
status: 'active',
|
|
createdAt: '2026-08-04T00:00:00.000Z',
|
|
updatedAt: '2026-08-04T00:00:00.000Z'
|
|
}
|
|
]),
|
|
getOrCreateRemoteConversation: vi.fn(() => ({
|
|
id: '00000000-0000-4000-8000-000000000402',
|
|
projectId: '00000000-0000-4000-8000-000000000401',
|
|
title: '企业微信 · ****er-1',
|
|
updatedAt: Date.now(),
|
|
messages: []
|
|
})),
|
|
appendConversationMessage: vi.fn(),
|
|
appendRemoteConversationMessage: vi.fn(),
|
|
listConversationQueueItems: vi.fn<
|
|
() => ConversationQueueItem[]
|
|
>(() => []),
|
|
listPendingScheduleQueueConversationIds: vi.fn(() => []),
|
|
listPendingConversationQueueIds: vi.fn(() => []),
|
|
getConversationQueueItem: vi.fn(),
|
|
getConversationUserQueuePayloadJson: vi.fn(),
|
|
isConversationUserQueueItemDispatching: vi.fn(() => true),
|
|
enqueueConversationUserInput: vi.fn(),
|
|
claimConversationQueueItem: vi.fn(),
|
|
completeConversationUserQueueItem: vi.fn(),
|
|
releaseConversationUserQueueItem: vi.fn(),
|
|
cancelConversationQueueItem: vi.fn(),
|
|
queueDueSchedules: vi.fn(() => []),
|
|
queueScheduleNow: vi.fn(),
|
|
completeScheduleRun: vi.fn(),
|
|
completeTaskScheduleRun: vi.fn(),
|
|
branchLocalConversation: vi.fn(() => ({
|
|
id: '00000000-0000-4000-8000-000000000403',
|
|
branch: {
|
|
sourceConversationId:
|
|
'00000000-0000-4000-8000-000000000402',
|
|
sourceTitle: '来源会话'
|
|
},
|
|
title: '来源会话 · 分支',
|
|
updatedAt: Date.now(),
|
|
messages: []
|
|
})),
|
|
listSchedules: vi.fn(() => []),
|
|
createSchedule: vi.fn(),
|
|
setScheduleEnabled: vi.fn(),
|
|
removeSchedule: vi.fn(),
|
|
upsertModelUsageCall: vi.fn(),
|
|
clearAssistantData: vi.fn(),
|
|
listExperts: vi.fn<() => Array<Record<string, unknown>>>(() => []),
|
|
getExpert: vi.fn(),
|
|
getConversation: vi.fn<
|
|
(conversationId: string) => Record<string, unknown>
|
|
>((conversationId) => ({
|
|
id: conversationId,
|
|
runtimeSelection: { provider: 'opencode' },
|
|
messages: []
|
|
})),
|
|
isConversationStoryGraphEnabled: vi.fn(() => true),
|
|
setConversationStoryGraphEnabled: vi.fn(),
|
|
getProject: vi.fn<
|
|
(projectId: string) => Record<string, unknown>
|
|
>((projectId) => ({
|
|
id: projectId,
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
}))
|
|
}
|
|
const webContents = { on: vi.fn(),
|
|
id: 9,
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn(),
|
|
once: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
isDestroyed: vi.fn(() => false)
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isFocused: vi.fn(() => true),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const contextManager = {
|
|
enrichRequest: vi.fn((request) => request),
|
|
ingestRemoteAttachment: vi.fn(async (attachment: {
|
|
name: string
|
|
size: number
|
|
kind: 'image' | 'file'
|
|
}) => ({
|
|
id: '00000000-0000-4000-8000-000000000498',
|
|
name: attachment.name,
|
|
size: attachment.size,
|
|
preview: '远程附件',
|
|
kind: attachment.kind === 'image' ? 'image' : 'text'
|
|
})),
|
|
remove: vi.fn(),
|
|
serializeForQueue: vi.fn(() => '[]'),
|
|
hasImageInputs: vi.fn(() => false),
|
|
validateForSend: vi.fn(),
|
|
restoreFromQueue: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const getResolvedSettings = vi.fn(
|
|
async (): Promise<Record<string, unknown>> => ({
|
|
modelProfiles: [{ id: defaultModelProfileId, name: 'Default', modelName: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none' }],
|
|
defaultModelProfileId,
|
|
subagentSmartRoutingEnabled: smartRoutingEnabled
|
|
})
|
|
)
|
|
const getPolicySettings = vi.fn(
|
|
async (): Promise<Record<string, unknown>> => ({
|
|
subagentSmartRoutingEnabled: smartRoutingEnabled
|
|
})
|
|
)
|
|
const getApplicationSettings = vi.fn(
|
|
async (): Promise<Record<string, unknown>> => ({
|
|
magicNotesEnabled,
|
|
heartbeatEnabled,
|
|
remoteProjectsEnabled: true
|
|
})
|
|
)
|
|
const onRuntimeSettingsChanged = vi.fn(async () => {})
|
|
const args: Parameters<typeof registerIpcHandlers> = [
|
|
window as never,
|
|
runtime as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{
|
|
getPolicySettings,
|
|
getResolvedSettings,
|
|
getRuntimeCustomization: vi.fn(async () => ({
|
|
opencode: {},
|
|
continue: { presets: [] }
|
|
})),
|
|
updateRuntimeCustomization: vi.fn(
|
|
async (settings: unknown) => settings
|
|
),
|
|
captureRollback: vi.fn(async () => ({
|
|
publicSettings: defaultRuntimeSettings,
|
|
restore: async () => defaultRuntimeSettings
|
|
})),
|
|
update: vi.fn(async (settings: unknown) => settings)
|
|
} as never,
|
|
(capabilityServiceOverride ?? {
|
|
setWebSearchEnabled: vi.fn(async () => ({}))
|
|
}) as never,
|
|
contextManager as never,
|
|
(knowledgeServiceOverride ?? {
|
|
database: { listKnowledgeBases: vi.fn(() => []) }
|
|
}) as never,
|
|
(imageDatabase ?? assistantDatabase) as never,
|
|
{} as never,
|
|
onRuntimeSettingsChanged,
|
|
onBeforeClearLocalData,
|
|
browserControl as never,
|
|
subagentService as never,
|
|
undefined,
|
|
{
|
|
get: getApplicationSettings,
|
|
onChanged: vi.fn()
|
|
} as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
selectedRuntimes as never,
|
|
undefined,
|
|
knowledgeGateway as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
goodbuddyConfigService as never
|
|
]
|
|
args[42] = imageService
|
|
args[43] = obsidianService
|
|
args[44] = nativeClientCoordinator
|
|
args[40] = nativeTerminalManager
|
|
if (applicationSettingsStore) args[14] = applicationSettingsStore
|
|
args[13] = channelSettingsStore
|
|
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(runtime)
|
|
const dispose = registerIpcHandlers(...args)
|
|
return {
|
|
assistantDatabase,
|
|
contextManager,
|
|
dispose,
|
|
getApplicationSettings,
|
|
getPolicySettings,
|
|
getResolvedSettings,
|
|
onRuntimeSettingsChanged,
|
|
clearHandler: electronMocks.handlers.get(
|
|
ipcChannels.appClearLocalData
|
|
),
|
|
handler: electronMocks.handlers.get(ipcChannels.agentRun),
|
|
statusHandler: electronMocks.handlers.get(ipcChannels.agentStatus),
|
|
cancelHandler: electronMocks.handlers.get(ipcChannels.agentCancel),
|
|
recoveryGetHandler: electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectRecoveryGet
|
|
),
|
|
recoveryRetryHandler: electronMocks.handlers.get(
|
|
ipcChannels.remoteProjectRecoveryRetry
|
|
),
|
|
branchHandler: electronMocks.handlers.get(
|
|
ipcChannels.conversationsBranchLocal
|
|
),
|
|
knowledgeSearchHandler: electronMocks.handlers.get(
|
|
ipcChannels.knowledgeSearch
|
|
),
|
|
knowledgeRebuildHandler: electronMocks.handlers.get(
|
|
ipcChannels.knowledgeRebuildLibrary
|
|
),
|
|
knowledgeCancelRebuildHandler: electronMocks.handlers.get(
|
|
ipcChannels.knowledgeCancelRebuild
|
|
),
|
|
webContents
|
|
}
|
|
}
|
|
|
|
const trustedEvent = (webContents: {
|
|
mainFrame: { url: string }
|
|
}) => ({
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
})
|
|
|
|
it.each([
|
|
['conversation', 'completed'], ['conversation', 'failed'],
|
|
['channel', 'completed'], ['channel', 'failed']
|
|
] as const)('uses the latest saved desktop notification preference for %s %s results', async (origin, status) => {
|
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notification-ipc-'))
|
|
const store = new ApplicationSettingsStore(join(directory, 'application.json'))
|
|
await store.update({ magicNotesEnabled: false })
|
|
const notify = vi.spyOn(desktopNotification, 'showDesktopNotificationWhenUnfocused').mockReturnValue(true)
|
|
let preference: 'default' | boolean | 'unreadable' = 'default'
|
|
const get = vi.spyOn(store, 'get')
|
|
const harness = createHarness({
|
|
capability: 'chat',
|
|
async *run(request: { requestId: string }) {
|
|
// Change the persisted choice after admission to detect stale startup/task snapshots.
|
|
if (typeof preference === 'boolean') await store.update({ desktopNotificationsEnabled: preference })
|
|
if (preference === 'unreadable') get.mockRejectedValueOnce(new Error('Settings unavailable'))
|
|
if (status === 'failed') throw new Error('Runtime failed')
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}, undefined, undefined, false, undefined, undefined, undefined, false,
|
|
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, store)
|
|
try {
|
|
for (preference of ['default', false, true, 'unreadable'] as const) {
|
|
notify.mockClear()
|
|
harness.webContents.send.mockClear()
|
|
harness.assistantDatabase.updateTaskStatus.mockClear()
|
|
if (origin === 'channel') {
|
|
await expect(channelMocks.executor!({
|
|
channel: 'wecom', eventId: crypto.randomUUID(), senderId: 'user-1',
|
|
conversationId: 'conversation-1', conversationType: 'direct',
|
|
text: 'Read only', mentioned: false
|
|
}, new AbortController().signal)).resolves.toMatchObject({ status })
|
|
} else {
|
|
const requestId = crypto.randomUUID()
|
|
await harness.handler!(trustedEvent(harness.webContents), {
|
|
requestId, conversationId: crypto.randomUUID(), prompt: 'Read only'
|
|
})
|
|
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent, expect.objectContaining({ requestId, type: status === 'completed' ? 'done' : 'error' })
|
|
))
|
|
}
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
expect.any(String), status, ...(status === 'failed' ? ['Runtime failed'] : [])
|
|
)
|
|
expect(notify).toHaveBeenCalledTimes(preference === 'default' || preference === true ? 1 : 0)
|
|
}
|
|
} finally {
|
|
await harness.dispose()
|
|
notify.mockRestore()
|
|
get.mockRestore()
|
|
await rm(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
function createManagedSshHarness(
|
|
selectedRuntime: Record<string, unknown>
|
|
) {
|
|
const projectId = '00000000-0000-4000-8000-000000000761'
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => selectedRuntime)
|
|
}
|
|
const harness = createHarness(
|
|
{
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
run: vi.fn()
|
|
},
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
id: projectId,
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000762',
|
|
remoteRootPath: '/srv/project'
|
|
}
|
|
})
|
|
return { harness, projectId, selectedRuntimes }
|
|
}
|
|
|
|
const managedSshRequest = (
|
|
projectId: string,
|
|
requestId: string,
|
|
conversationId: string
|
|
) => ({
|
|
requestId,
|
|
conversationId,
|
|
projectId,
|
|
runtimeSelection: { provider: 'opencode' as const },
|
|
prompt: 'continue on Agent',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
|
|
it.each(['completed', 'failed', 'cancelled'] as const)('persists reply duration through the production lease and %s path', async (status) => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const projectId = database.listProjects()[0]!.id
|
|
const conversationId = crypto.randomUUID()
|
|
const requestId = crypto.randomUUID()
|
|
let now = Date.now()
|
|
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
|
|
database.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Timing', updatedAt: now }, messages: [] }])
|
|
let finish!: () => void
|
|
const gate = new Promise<void>(resolve => { finish = resolve })
|
|
let started!: () => void
|
|
const ready = new Promise<void>(resolve => { started = resolve })
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
|
started()
|
|
await gate
|
|
if (status !== 'completed') throw new Error('Stopped')
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run },
|
|
undefined, undefined, false, undefined, undefined, undefined, false,
|
|
undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
const event = trustedEvent(harness.webContents)
|
|
await harness.handler!(event, { requestId, conversationId, projectId, prompt: 'Run' })
|
|
await ready
|
|
now += 5000
|
|
const read = () => electronMocks.handlers.get(ipcChannels.tasksExecutionStats)!(event, { conversationId })
|
|
expect(await read()).toMatchObject({ durationMs: 5000, runningCount: 1, incomplete: false })
|
|
if (status === 'cancelled') {
|
|
harness.cancelHandler!(event, requestId)
|
|
await vi.waitFor(async () => expect(await read()).toMatchObject({ runningCount: 0 }))
|
|
now += 60_000
|
|
expect(await read()).toMatchObject({ durationMs: 5000, runningCount: 0 })
|
|
}
|
|
finish()
|
|
await vi.waitFor(() => expect(database.getTask(requestId).status).toBe(status))
|
|
now += 60_000
|
|
expect(await read()).toMatchObject({ durationMs: 5000, runningCount: 0 })
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.tasksExecutionStatsChanged)
|
|
} finally {
|
|
finish()
|
|
await harness.dispose()
|
|
clock.mockRestore()
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it.each(['answer', 'runtime-event'] as const)('pauses ordinary question timing until the last %s resolution and retains failed answers', async (resolution) => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const projectId = database.listProjects()[0]!.id
|
|
const conversationId = crypto.randomUUID()
|
|
const requestId = crypto.randomUUID()
|
|
const questionIds = [crypto.randomUUID(), crypto.randomUUID()]
|
|
let now = Date.now()
|
|
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
|
|
database.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Questions', updatedAt: now }, messages: [] }])
|
|
const deferred = () => {
|
|
let resolve!: () => void
|
|
const promise = new Promise<void>(complete => { resolve = complete })
|
|
return { promise, resolve }
|
|
}
|
|
const ready = deferred()
|
|
const finish = deferred()
|
|
const steps = questionIds.map(() => ({ release: deferred(), processed: deferred() }))
|
|
const respondToQuestion = vi.fn().mockRejectedValueOnce(new Error('Answer delivery failed')).mockResolvedValue(undefined)
|
|
const run = vi.fn(async function* () {
|
|
now += 2000
|
|
for (const questionId of questionIds) yield {
|
|
type: 'question' as const, requestId, questionId,
|
|
questions: [{ header: 'Input', question: 'Continue?', options: [], custom: true, multiple: false }]
|
|
}
|
|
ready.resolve()
|
|
for (const [index, questionId] of questionIds.entries()) {
|
|
await steps[index]!.release.promise
|
|
if (resolution === 'runtime-event') yield { type: 'question-resolved' as const, requestId, questionId }
|
|
steps[index]!.processed.resolve()
|
|
}
|
|
await finish.promise
|
|
yield { type: 'done' as const, requestId }
|
|
})
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run, respondToQuestion },
|
|
undefined, undefined, false, undefined, undefined, undefined, false,
|
|
undefined, undefined, undefined, undefined, database)
|
|
const read = () => database.getExecutionStats({ conversationId })
|
|
try {
|
|
const event = trustedEvent(harness.webContents)
|
|
await harness.handler!(event, { requestId, conversationId, projectId, prompt: 'Ask me' })
|
|
await ready.promise
|
|
expect(database.getTask(requestId).status).toBe('waiting_approval')
|
|
now += 60_000
|
|
expect(await read()).toMatchObject({ durationMs: 2000, runningCount: 0 })
|
|
const answer = electronMocks.handlers.get(ipcChannels.agentQuestionRespond)!
|
|
await expect(answer(event, { questionId: questionIds[0], answers: [['Yes']] })).rejects.toThrow('Answer delivery failed')
|
|
now += 60_000
|
|
expect(database.getTask(requestId).status).toBe('waiting_approval')
|
|
expect(await read()).toMatchObject({ durationMs: 2000, runningCount: 0 })
|
|
for (const [index, questionId] of questionIds.entries()) {
|
|
if (resolution === 'answer') await answer(event, { questionId, answers: index === 0 ? [['Yes']] : [] })
|
|
steps[index]!.release.resolve()
|
|
await steps[index]!.processed.promise
|
|
if (index === 0) {
|
|
now += 60_000
|
|
expect(database.getTask(requestId).status).toBe('waiting_approval')
|
|
expect(await read()).toMatchObject({ durationMs: 2000, runningCount: 0 })
|
|
}
|
|
}
|
|
expect(database.getTask(requestId).status).toBe('running')
|
|
now += 3000
|
|
finish.resolve()
|
|
await vi.waitFor(() => expect(database.getTask(requestId).status).toBe('completed'))
|
|
expect(await read()).toMatchObject({ durationMs: 5000, runningCount: 0 })
|
|
if (resolution === 'answer') expect(respondToQuestion).toHaveBeenLastCalledWith(questionIds[1], undefined)
|
|
} finally {
|
|
for (const step of steps) step.release.resolve()
|
|
finish.resolve()
|
|
await harness.dispose()
|
|
clock.mockRestore()
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('binds the production image service and routes regeneration and deletion through IPC', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const conversationId = crypto.randomUUID()
|
|
const userId = crypto.randomUUID()
|
|
const messageId = crypto.randomUUID()
|
|
const header = { id: conversationId, title: 'Image IPC', updatedAt: Date.now() }
|
|
database.saveLocalConversations([{ header, messages: [] }])
|
|
const profile = { id: crypto.randomUUID(), name: 'Image model', modelName: 'image-test', protocol: 'openai-images-generations',
|
|
baseUrl: 'https://image.test/v1', authentication: 'none' as const, allowConversationInvocation: true }
|
|
const fetcher = vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: 'iVBORw0KGgo=' }] }))
|
|
const service = new ImageGenerationService({ database, getSettings: async () => ({ modelProfiles: [profile], defaultImageModelProfileId: profile.id }), fetcher })
|
|
service.initialize()
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
|
expect(database.getConversation(conversationId).messages.map(message => message.id)).toEqual([userId, messageId])
|
|
expect(request.imageToolBinding?.context.messageId).toBe(messageId)
|
|
expect(await request.imageToolBinding?.describe()).toContain(profile.id)
|
|
expect((await request.imageToolBinding!.call({ intent: 'create', prompt: 'A blue circle' }, 'image-call')).state).toBe('completed')
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'text', supportsToolExecution: true, run },
|
|
undefined, undefined, false, undefined, undefined, undefined, false, undefined, undefined, undefined, service, database)
|
|
const event = trustedEvent(harness.webContents)
|
|
try {
|
|
const requestId = crypto.randomUUID()
|
|
await harness.handler!(event, { requestId, conversationId, prompt: 'Draw a circle', currentUserMessageId: userId, currentAssistantMessageId: messageId })
|
|
await vi.waitFor(() => expect(database.getTask(requestId).status).toBe('completed'))
|
|
const original = database.getConversation(conversationId).messages[1]!.imageOperations![0]!
|
|
expect(database.getArtifact(original.artifactIds[0]!).content).toContain('iVBORw0KGgo=')
|
|
const regenerate = electronMocks.handlers.get(ipcChannels.imageOperationRegenerate)!
|
|
const cancel = electronMocks.handlers.get(ipcChannels.imageOperationCancel)!
|
|
await expect(Promise.resolve().then(() => cancel(event, { conversationId, operationId: original.id, prompt: 'injected' }))).rejects.toThrow()
|
|
const regenerated = await regenerate(event, { conversationId, operationId: original.id }) as typeof original
|
|
expect(regenerated.id).not.toBe(original.id)
|
|
expect(regenerated.messageId).toBe(messageId)
|
|
// Regeneration keeps the originating task so its usage satisfies the task foreign key.
|
|
expect(regenerated.requestId).toBe(original.requestId)
|
|
expect(regenerated.callId).not.toBe(original.callId)
|
|
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
const cancelConversation = vi.spyOn(service, 'cancelConversation')
|
|
await electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)
|
|
expect(cancelConversation).toHaveBeenCalledWith(conversationId)
|
|
expect(() => database.getConversation(conversationId)).toThrow()
|
|
} finally {
|
|
await harness.dispose()
|
|
await service.dispose()
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('persists uploads before dispatch while withholding image bytes from a nonvisual text model', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const conversationId = crypto.randomUUID()
|
|
const userId = crypto.randomUUID()
|
|
database.saveLocalConversations([{ header: { id: conversationId, title: 'Upload', updatedAt: Date.now() }, messages: [] }])
|
|
const service = new ImageGenerationService({ database, getSettings: async () => ({ modelProfiles: [] }) })
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
|
expect(request.images).toBeUndefined()
|
|
expect(request.trustedInstructions).toContain('cannot view')
|
|
const sources = database.getConversation(conversationId).messages.find(message => message.id === userId)!.imageSourceArtifactIds!
|
|
expect(sources).toHaveLength(1)
|
|
expect(database.getArtifact(sources[0]!).content).toContain('iVBORw0KGgo=')
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'text', supportsToolExecution: true, run },
|
|
undefined, undefined, false, undefined, undefined, undefined, false, undefined, undefined, undefined, service, database)
|
|
harness.contextManager.enrichRequest.mockImplementation(request => ({ ...request, images: [{ name: 'Upload', mediaType: 'image/png', data: 'iVBORw0KGgo=' }] }))
|
|
try {
|
|
const requestId = crypto.randomUUID()
|
|
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId, prompt: 'Turn it red', currentUserMessageId: userId, currentAssistantMessageId: crypto.randomUUID() })
|
|
await vi.waitFor(() => expect(database.getTask(requestId).status).toBe('completed'))
|
|
expect(run).toHaveBeenCalledOnce()
|
|
} finally { await harness.dispose(); await service.dispose(); database.close() }
|
|
})
|
|
|
|
it('restores image context at the IPC boundary and persists the quiet notice on its artifact event', async () => {
|
|
const png = 'iVBORw0KGgo='
|
|
const artifactId = '00000000-0000-4000-8000-000000000301'
|
|
const run = vi.fn(async function* (request: {
|
|
requestId: string
|
|
images?: Array<{ data: string }>
|
|
}) {
|
|
expect(request.images?.[0]?.data).toBe(png)
|
|
yield {
|
|
requestId: request.requestId, type: 'generated-image', mimeType: 'image/png',
|
|
data: png, title: '编辑图片', imageContextNotice: 'editing-unavailable'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
})
|
|
const harness = createHarness({
|
|
runtimeId: 'model', capability: 'image-generation',
|
|
supportsToolExecution: false, run
|
|
})
|
|
harness.assistantDatabase.getArtifact.mockReturnValue({
|
|
id: artifactId, kind: 'image', content: `data:image/png;base64,${png}`
|
|
})
|
|
try {
|
|
await harness.handler!(trustedEvent(harness.webContents), {
|
|
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
|
|
prompt: '把圆改成蓝色', imageContextArtifactIds: [artifactId]
|
|
})
|
|
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus)
|
|
.toHaveBeenCalledWith(expect.any(String), 'completed'))
|
|
expect(harness.assistantDatabase.getArtifact).toHaveBeenCalledWith(artifactId)
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({
|
|
type: 'artifact', imageContextNotice: 'editing-unavailable'
|
|
})
|
|
)
|
|
} finally {
|
|
await harness.dispose()
|
|
}
|
|
})
|
|
|
|
it('creates custom scheduled tasks without mode metadata', async () => {
|
|
const harness = createHarness({
|
|
runtimeId: 'continue',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run() {
|
|
yield { type: 'done', requestId: 'unused' } as const
|
|
}
|
|
})
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000705'
|
|
const createInput = {
|
|
projectId: '00000000-0000-4000-8000-000000000401',
|
|
conversationId,
|
|
title: '每周汇总',
|
|
prompt: '汇总本周进展',
|
|
recurrence: 'weekly' as const,
|
|
nextRunAt: new Date(Date.now() + 60_000).toISOString()
|
|
}
|
|
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.schedulesCreate
|
|
)?.(trustedEvent(harness.webContents), createInput)
|
|
|
|
expect(
|
|
harness.assistantDatabase.createSchedule
|
|
).toHaveBeenCalledWith({
|
|
...createInput
|
|
})
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each([false, undefined])('heartbeat enabled gates block startup, timer claims, and manual runs (%s)', async (enabled) => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2026-09-23T12:00:00.000Z'))
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const config = database.createHeartbeatConfig({
|
|
name: 'Due review', scope: { kind: 'global' }, timezone: 'UTC',
|
|
recurrence: { type: 'daily', localTime: '11:00' }, enabled: true,
|
|
lookbackHours: 24, retentionDays: 30
|
|
}, new Date('2026-09-23T00:00:00.000Z'))
|
|
const claimDue = vi.spyOn(database, 'claimDueHeartbeats')
|
|
const claimNow = vi.spyOn(database, 'claimHeartbeatNow')
|
|
const activityRead = vi.spyOn(database, 'listSupervisionActivity')
|
|
const collect = vi.spyOn(database, 'buildHeartbeatInput')
|
|
const queueSchedules = vi.spyOn(database, 'queueDueSchedules')
|
|
const run = vi.fn()
|
|
const harness = createHarness({ capability: 'chat', run }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database, enabled)
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(60_000)
|
|
expect(claimDue).not.toHaveBeenCalled()
|
|
expect(queueSchedules).toHaveBeenCalledTimes(3)
|
|
await expect(electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), {
|
|
id: config.id, idempotencyKey: crypto.randomUUID()
|
|
})).rejects.toThrow('智能督导未启用')
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), {
|
|
trigger: 'manual', scope: { kind: 'global' },
|
|
timeRange: { from: '2026-09-22T00:00:00.000Z', to: '2026-09-23T12:00:00.000Z' }
|
|
})).rejects.toThrow('智能督导未启用')
|
|
expect(claimNow).not.toHaveBeenCalled()
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionActivity)!(trustedEvent(harness.webContents), {})).resolves.toEqual([])
|
|
expect(activityRead).not.toHaveBeenCalled()
|
|
expect(collect).not.toHaveBeenCalled()
|
|
expect(run).not.toHaveBeenCalled()
|
|
expect(database.listHeartbeatRuns()).toEqual([])
|
|
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
|
|
})
|
|
|
|
it.each(['manual', 'scheduled'] as const)('a %s heartbeat waits for an active review and then only makes review requests', async (trigger) => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2026-09-23T12:00:00.000Z'))
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Evidence', updatedAt: Date.now() },
|
|
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this decision', createdAt: Date.now() }] }])
|
|
let finishSupervisor!: () => void
|
|
const supervisorWait = new Promise<void>(resolve => { finishSupervisor = resolve })
|
|
let firstSupervisor = true
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest, signal: AbortSignal) {
|
|
if (firstSupervisor) {
|
|
firstSupervisor = false
|
|
await supervisorWait
|
|
}
|
|
signal.throwIfAborted()
|
|
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify(
|
|
{ summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorOrganizeTimeoutSeconds: 600 })
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
const supervisor = electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), {
|
|
trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange: { from: new Date(Date.now() - 1000).toISOString(), to: new Date().toISOString() }
|
|
})
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(run).toHaveBeenCalledOnce()
|
|
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'New evidence', updatedAt: Date.now() },
|
|
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review the next decision', createdAt: Date.now() }] }])
|
|
const config = database.createHeartbeatConfig({ name: 'Queued review', scope: { kind: 'global' }, timezone: 'UTC',
|
|
recurrence: { type: 'daily', localTime: '11:00' }, enabled: trigger === 'scheduled', lookbackHours: 24, retentionDays: 30 }, new Date('2026-09-23T00:00:00.000Z'))
|
|
const invoke = (key: string) => electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), { id: config.id, idempotencyKey: key })
|
|
const manual = trigger === 'manual' ? invoke('first') : undefined
|
|
const duplicate = trigger === 'manual' ? invoke('second') : undefined
|
|
await vi.advanceTimersByTimeAsync(trigger === 'scheduled' ? 30_000 : 0)
|
|
// The heartbeat finishes its own trigger at once; the review waits for the active one.
|
|
const [heartbeatRun] = database.listHeartbeatRuns(config.id)
|
|
expect(heartbeatRun).toMatchObject({ status: 'completed' })
|
|
expect(database.listSupervisionActivity().find(row => row.id === heartbeatRun!.id)?.status).toBe('running')
|
|
expect(run).toHaveBeenCalledOnce()
|
|
finishSupervisor()
|
|
await supervisor
|
|
await vi.waitFor(() => expect(database.listSupervisionActivity().find(row => row.id === heartbeatRun!.id)?.supervisionStatus).toBe('completed'))
|
|
if (manual) {
|
|
await expect(manual).resolves.toMatchObject({ id: heartbeatRun!.id })
|
|
await expect(duplicate).resolves.toMatchObject({ id: heartbeatRun!.id })
|
|
}
|
|
expect(run.mock.calls.some(([request]) => request.conversationId?.startsWith('heartbeat:'))).toBe(false)
|
|
expect(database.listHeartbeatEntries(config.id)).toEqual([])
|
|
// Manual re-analysis leaf + two conversation leaves + one navigation merge; no report call.
|
|
expect(run).toHaveBeenCalledTimes(4)
|
|
} finally { finishSupervisor(); await harness.dispose(); database.close(); vi.useRealTimers() }
|
|
})
|
|
|
|
it('times out a heartbeat-triggered review at the organize timeout and records it on the heartbeat', async () => {
|
|
vi.useFakeTimers()
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Evidence', updatedAt: Date.now() },
|
|
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this decision', createdAt: Date.now() }] }])
|
|
const config = database.createHeartbeatConfig({ name: 'Review', scope: { kind: 'global' }, timezone: 'UTC',
|
|
recurrence: { type: 'daily', localTime: '11:00' }, enabled: false, lookbackHours: 24, retentionDays: 30 })
|
|
const releaseConversation = vi.fn(async () => undefined)
|
|
let signal: AbortSignal | undefined
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest, inputSignal: AbortSignal) {
|
|
signal = inputSignal
|
|
await new Promise<void>(resolve => inputSignal.addEventListener('abort', () => resolve(), { once: true }))
|
|
inputSignal.throwIfAborted()
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorOrganizeTimeoutSeconds: 30 })
|
|
const pending = electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), { id: config.id, idempotencyKey: crypto.randomUUID() })
|
|
await vi.advanceTimersByTimeAsync(29_999)
|
|
expect(signal?.aborted).toBe(false)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
const heartbeatRun = await pending as { id: string }
|
|
expect(heartbeatRun).toMatchObject({ status: 'completed' })
|
|
expect(run).toHaveBeenCalledOnce()
|
|
expect(database.listSupervisionActivity().find(row => row.id === heartbeatRun.id)).toMatchObject({
|
|
status: 'failed', error: '监督者整理模型阶段超过 30 秒,已停止本次回顾;未保存结果' })
|
|
expect(database.listSupervisionResults()).toEqual([])
|
|
expect(database.listHeartbeatEntries()).toEqual([])
|
|
expect(releaseConversation).toHaveBeenCalledOnce()
|
|
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
|
|
})
|
|
|
|
it('shutdown aborts active supervision and a queued heartbeat review before runtime dispatch', async () => {
|
|
vi.useFakeTimers()
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Evidence', updatedAt: Date.now() },
|
|
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this decision', createdAt: Date.now() }] }])
|
|
const config = database.createHeartbeatConfig({ name: 'Review', scope: { kind: 'global' }, timezone: 'UTC',
|
|
recurrence: { type: 'daily', localTime: '11:00' }, enabled: false, lookbackHours: 24, retentionDays: 30 })
|
|
let signal: AbortSignal | undefined
|
|
const releaseConversation = vi.fn(async () => undefined)
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest, inputSignal: AbortSignal) {
|
|
signal = inputSignal
|
|
await new Promise<void>(resolve => inputSignal.addEventListener('abort', () => resolve(), { once: true }))
|
|
inputSignal.throwIfAborted()
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
|
|
const organize = electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), {
|
|
trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: new Date(Date.now() - 1000).toISOString(), to: new Date().toISOString() }
|
|
})
|
|
const rejected = expect(organize).rejects.toThrow('shutting down')
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
const heartbeat = electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), { id: config.id, idempotencyKey: crypto.randomUUID() })
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(run).toHaveBeenCalledOnce()
|
|
await harness.dispose()
|
|
await rejected
|
|
await heartbeat
|
|
expect(signal?.aborted).toBe(true)
|
|
expect(run).toHaveBeenCalledOnce()
|
|
expect(releaseConversation).toHaveBeenCalledOnce()
|
|
expect(database.listSupervisionResults()).toEqual([])
|
|
expect(database.listHeartbeatEntries()).toEqual([])
|
|
} finally { database.close(); vi.useRealTimers() }
|
|
})
|
|
it('heartbeat enabled gates leave enabled startup and ticks idle without configured plans', async () => {
|
|
vi.useFakeTimers()
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const claimDue = vi.spyOn(database, 'claimDueHeartbeats')
|
|
const collect = vi.spyOn(database, 'buildHeartbeatInput')
|
|
const run = vi.fn()
|
|
const harness = createHarness({ capability: 'chat', run }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database, true)
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(60_000)
|
|
expect(claimDue).toHaveBeenCalledTimes(3)
|
|
expect(database.listHeartbeatConfigs()).toEqual([])
|
|
expect(database.listHeartbeatRuns()).toEqual([])
|
|
expect(database.listSupervisionResults()).toEqual([])
|
|
expect(collect).not.toHaveBeenCalled()
|
|
expect(run).not.toHaveBeenCalled()
|
|
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
|
|
})
|
|
|
|
it('a suggest plan phrases rule-selected candidates once after the review and gates suggestion IPC', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const now = Date.now()
|
|
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Scheduling', updatedAt: now },
|
|
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Page size is still undecided', createdAt: now }] }])
|
|
const config = database.createHeartbeatConfig({ name: 'Review', scope: { kind: 'global' }, timezone: 'UTC',
|
|
recurrence: { type: 'daily', localTime: '11:00' }, enabled: false, lookbackHours: 24, retentionDays: 30 })
|
|
const prompts: string[] = []
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
|
prompts.push(request.prompt)
|
|
const output = request.prompt.includes('CANDIDATES:')
|
|
? { suggestions: [{ ref: 'c1', title: 'Decide the page size', detail: 'Pick 50 or 100 next time.' }] }
|
|
: { summary: 'Review', changeDigest: '', openItems: ['Decide the page size'], events: [], entities: [], entityChanges: [], relations: [] }
|
|
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify(output) }
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
|
|
await electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), { id: config.id, idempotencyKey: crypto.randomUUID() })
|
|
expect(run).toHaveBeenCalledTimes(2)
|
|
expect(prompts[1]).toContain('CANDIDATES:')
|
|
expect(prompts[1]).not.toContain('Page size is still undecided')
|
|
const list = electronMocks.handlers.get(ipcChannels.supervisionSuggestions)!
|
|
const [suggestion] = await list(trustedEvent(harness.webContents), {}) as Array<{ id: string; title: string; kind: string }>
|
|
expect(suggestion).toMatchObject({ kind: 'open_item', title: 'Decide the page size' })
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: false })
|
|
await expect(list(trustedEvent(harness.webContents), {})).resolves.toEqual([])
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionSuggestionAction)!(trustedEvent(harness.webContents),
|
|
{ id: suggestion!.id, action: 'accept' })).rejects.toThrow('Supervisor is disabled')
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionSuggestionAction)!(trustedEvent(harness.webContents),
|
|
{ id: suggestion!.id, action: 'accept' })).resolves.toMatchObject({ status: 'accepted', taskId: expect.any(String) })
|
|
} finally { await harness.dispose(); database.close() }
|
|
})
|
|
|
|
it('heartbeat enabled gates follow live toggles and suppress suggestions after an in-flight review', async () => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2026-09-23T12:00:00.000Z'))
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'New input', updatedAt: Date.now() },
|
|
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this new decision', createdAt: Date.now() }] }])
|
|
database.createHeartbeatConfig({
|
|
name: 'Due review', scope: { kind: 'global' }, timezone: 'UTC',
|
|
recurrence: { type: 'daily', localTime: '11:00' }, enabled: true,
|
|
lookbackHours: 24, retentionDays: 30
|
|
}, new Date('2026-09-23T00:00:00.000Z'))
|
|
const claimDue = vi.spyOn(database, 'claimDueHeartbeats')
|
|
let finish!: () => void
|
|
const pending = new Promise<void>(resolve => { finish = resolve })
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
|
await pending
|
|
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify(
|
|
{ summary: 'Review', changeDigest: '', openItems: ['Decide the page size'], events: [], entities: [], entityChanges: [], relations: [] }) }
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
const harness = createHarness({ capability: 'chat', run }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database, false)
|
|
try {
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(claimDue).not.toHaveBeenCalled()
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
|
|
await vi.advanceTimersByTimeAsync(30_000)
|
|
expect(claimDue).toHaveBeenCalledOnce()
|
|
expect(run).toHaveBeenCalledOnce()
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: false })
|
|
finish()
|
|
await vi.advanceTimersByTimeAsync(30_000)
|
|
// An already running review may finish, but no suggestion step follows once disabled.
|
|
expect(database.listHeartbeatRuns()).toEqual([expect.objectContaining({ status: 'completed' })])
|
|
expect(database.listSupervisionResults()).toHaveLength(1)
|
|
expect(database.supervisionSuggestions().list('all', 50, 0)).toEqual([])
|
|
expect(run).toHaveBeenCalledOnce()
|
|
expect(claimDue).toHaveBeenCalledOnce()
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
|
|
const config = database.listHeartbeatConfigs()[0]!
|
|
// Nothing changed since the last review: no model call.
|
|
await expect(electronMocks.handlers.get(ipcChannels.heartbeatsRunNow)!(trustedEvent(harness.webContents), {
|
|
id: config.id, idempotencyKey: crypto.randomUUID()
|
|
})).resolves.toMatchObject({ status: 'no_change' })
|
|
expect(run).toHaveBeenCalledOnce()
|
|
} finally { finish(); await harness.dispose(); database.close(); vi.useRealTimers() }
|
|
})
|
|
it('production supervision IPC cancels the runtime and holds admission until conversation cleanup settles', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const now = Date.now()
|
|
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Cancel review', updatedAt: now },
|
|
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this', createdAt: now }] }])
|
|
let entered!: () => void, cleaning!: () => void, finish!: () => void
|
|
const started = new Promise<void>(resolve => { entered = resolve })
|
|
const cleanupStarted = new Promise<void>(resolve => { cleaning = resolve })
|
|
const cleanup = new Promise<void>(resolve => { finish = resolve })
|
|
const run = vi.fn(async function* (_input: AgentExecutionRequest, signal: AbortSignal) {
|
|
entered()
|
|
await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
|
|
signal.throwIfAborted()
|
|
yield { type: 'done' as const, requestId: _input.requestId }
|
|
})
|
|
const releaseConversation = vi.fn(async () => { cleaning(); await cleanup })
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
|
|
const event = trustedEvent(harness.webContents)
|
|
const request = { trigger: 'manual', scope: { kind: 'global' }, timeRange: {
|
|
from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString()
|
|
} }
|
|
const pending = electronMocks.handlers.get(ipcChannels.supervisionRun)!(event, request)
|
|
await started
|
|
const runId = database.listSupervisionActivity()[0]!.id
|
|
const input = { runId }
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionCancel)!({ sender: {} }, input)).rejects.toThrow()
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionCancel)!(event, { runId: 'invalid' })).rejects.toThrow()
|
|
const cancellation = electronMocks.handlers.get(ipcChannels.supervisionCancel)!(event, input)
|
|
await cleanupStarted
|
|
expect(database.listSupervisionActivity()[0]!.status).toBe('cancelled')
|
|
expect(await electronMocks.handlers.get(ipcChannels.supervisionExecution)!(event)).toEqual({ active: true, runId, stopping: 'cancelled' })
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionRun)!(event, request)).rejects.toThrow('SUPERVISION_REVIEW_BUSY')
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!(event, input)).rejects.toThrow('SUPERVISION_REVIEW_BUSY')
|
|
finish()
|
|
await cancellation
|
|
await expect(pending).resolves.toMatchObject({ status: 'cancelled' })
|
|
expect(await electronMocks.handlers.get(ipcChannels.supervisionExecution)!(event)).toMatchObject({ active: false })
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!(event, input)).rejects.toThrow('SUPERVISION_REVIEW_CANCELLED')
|
|
expect(database.listSupervisionResults()).toEqual([])
|
|
expect(run).toHaveBeenCalledTimes(1)
|
|
expect(releaseConversation).toHaveBeenCalledTimes(1)
|
|
} finally { finish(); await harness.dispose(); database.close() }
|
|
})
|
|
|
|
it('routes native clients through validated owner-scoped IPC and cleans up the window', async () => {
|
|
const coordinator = {
|
|
open: vi.fn(async () => ({ kind: 'browser' as const, serviceId: 'native-service' })),
|
|
get: vi.fn(async () => ({ serviceId: 'native-service' })),
|
|
stop: vi.fn(async () => {}),
|
|
closeOwner: vi.fn(async () => {})
|
|
}
|
|
const harness = createHarness({}, undefined, undefined, false, undefined,
|
|
undefined, undefined, false, undefined, undefined, undefined, undefined, undefined, false, undefined, coordinator)
|
|
const event = trustedEvent(harness.webContents)
|
|
const input = { conversationId: '00000000-0000-4000-8000-000000000401' }
|
|
const open = electronMocks.handlers.get(ipcChannels.runtimeNativeClientOpen)!
|
|
const get = electronMocks.handlers.get(ipcChannels.runtimeNativeClientGet)!
|
|
const stop = electronMocks.handlers.get(ipcChannels.runtimeNativeClientStop)!
|
|
await expect(open(event, input)).resolves.toEqual({ kind: 'browser', serviceId: 'native-service' })
|
|
expect(coordinator.open).toHaveBeenCalledWith(9, input.conversationId)
|
|
await expect(get(event, input)).resolves.toEqual({ serviceId: 'native-service' })
|
|
expect(coordinator.get).toHaveBeenCalledWith(9, input.conversationId)
|
|
await stop(event, { serviceId: 'native-service' })
|
|
expect(coordinator.stop).toHaveBeenCalledWith(9, 'native-service')
|
|
await expect(open(event, { ...input, runtime: 'continue' })).rejects.toThrow()
|
|
await expect(open({ ...event, sender: { id: 99 } }, input)).rejects.toThrow()
|
|
await expect(stop(event, { serviceId: '' })).rejects.toThrow()
|
|
expect(coordinator.open).toHaveBeenCalledTimes(1)
|
|
expect(harness.webContents.once).toHaveBeenCalledWith('destroyed', expect.any(Function))
|
|
await harness.dispose()
|
|
expect(coordinator.closeOwner).toHaveBeenCalledWith(9)
|
|
})
|
|
|
|
it('production supervision IPC pauses in-flight work and resumes saved batches without replaying them', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const now = Date.now()
|
|
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Resume review', updatedAt: now },
|
|
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: '中🧭'.repeat(1500), createdAt: now }] }])
|
|
let calls = 0, entered!: () => void
|
|
const summary = 'Retained review detail. '.repeat(150)
|
|
const second = new Promise<void>(resolve => { entered = resolve })
|
|
const run = vi.fn(async function* (input: AgentExecutionRequest, signal: AbortSignal) {
|
|
if (++calls === 2) {
|
|
entered()
|
|
await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
|
|
signal.throwIfAborted()
|
|
}
|
|
const navigation = input.prompt.includes('These inputs are navigation summaries')
|
|
const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as Array<{ id: string }>
|
|
if (navigation) expect(input.prompt).toContain('"entities":[]')
|
|
else expect(input.prompt).toContain('KNOWN ENTITIES is empty. Every entity is new; omit candidateRef')
|
|
yield { type: 'text' as const, requestId: input.requestId, delta: JSON.stringify({ summary, changeDigest: '', openItems: [], events: [],
|
|
entities: navigation ? [] : ['Atlas', 'Beacon'].map((label, index) => ({ id: label, label, description: label,
|
|
persistedId: index ? null : '', sourceReferenceIds: [evidence[0]!.id] })), entityChanges: [], relations: [] }) }
|
|
yield { type: 'done' as const, requestId: input.requestId }
|
|
})
|
|
const releaseConversation = vi.fn(async () => undefined)
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelConcurrency: 1,
|
|
supervisionReview: { pageSize: 1, batchCharacters: 1000, batchMessages: 1, executionSeconds: 300 } })
|
|
const event = trustedEvent(harness.webContents)
|
|
const pending = electronMocks.handlers.get(ipcChannels.supervisionRun)!(event, { trigger: 'manual', scope: { kind: 'global' }, timeRange: {
|
|
from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString()
|
|
} })
|
|
await Promise.race([second, Promise.resolve(pending).then(() => { throw new Error('Review ended before its second batch') })])
|
|
const activity = database.listSupervisionActivity()[0]!
|
|
expect(activity.reviewProgress).toMatchObject({ batches: 1, complete: false })
|
|
const input = { runId: activity.id }
|
|
await electronMocks.handlers.get(ipcChannels.supervisionPause)!(event, input)
|
|
await expect(pending).resolves.toMatchObject({ status: 'paused' })
|
|
const saved = database.supervisionReviewStore().batches(activity.id)[0]!
|
|
expect(saved.output.entities).toHaveLength(2)
|
|
for (const entity of saved.output.entities) expect(entity).not.toHaveProperty('persistedId')
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!(event, input)).resolves.toMatchObject({ status: 'completed', coverage: { remainingSources: 0 } })
|
|
expect(database.supervisionReviewStore().batches(activity.id)[0]).toEqual(saved)
|
|
const published = database.listSupervisionResults()[0]!
|
|
expect(published.summary).toBe(summary)
|
|
expect(saved.output.summary).toBe(summary)
|
|
const source = database.getSupervisionSource(published.sourceId!)!
|
|
const context = electronMocks.handlers.get(ipcChannels.supervisionSourceContext)!(event, { sourceId: source.id })
|
|
expect(await context).toMatchObject({ content: source.content,
|
|
messageId: JSON.parse(String(source.locatorJson)).messageId })
|
|
expect(releaseConversation).toHaveBeenCalledTimes(calls)
|
|
expect(database.listSupervisionActivity()).toHaveLength(1)
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!({ sender: {} }, input)).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() => electronMocks.handlers.get(ipcChannels.supervisionBatches)!(event, { ...input, limit: 21 }))).rejects.toThrow()
|
|
} finally { await harness.dispose(); database.close() }
|
|
})
|
|
|
|
it('routes Supervisor overview, graph and stories IPC through the real readonly worker without Main fallback', async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), 'supervision-ipc-worker-'))
|
|
const worker = join(directory, 'worker.cjs')
|
|
execFileSync(process.execPath, ['-e', `require('esbuild').buildSync(${JSON.stringify({
|
|
entryPoints: [join(process.cwd(), 'src/main/readonly-query-worker.ts')], outfile: worker,
|
|
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent'
|
|
})})`], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } })
|
|
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
|
database.initialize(directory)
|
|
const at = '2026-10-01T12:00:00.000Z'
|
|
database.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: at, to: at } },
|
|
evidence: [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: 'Source', content: 'Full body', occurredAt: at }],
|
|
output: { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } })
|
|
const overview = database.listSupervisionResults()
|
|
const resultId = overview[0]!.id
|
|
const sourceId = overview[0]!.sourceId!
|
|
const harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
|
|
false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
|
|
const event = trustedEvent(harness.webContents)
|
|
const requests = [[ipcChannels.supervisionOverview, {}], [ipcChannels.supervisionGraph, { resultId }],
|
|
[ipcChannels.supervisionStories, { scope: { kind: 'global' } }]] as const
|
|
const reader = new ReadonlyQueryReader('assistant', join(directory, 'assistant.sqlite'), worker)
|
|
storageReaders.set(database, reader)
|
|
const reads = [vi.spyOn(database, 'listSupervisionResults'), vi.spyOn(database, 'getSupervisionGraph'), vi.spyOn(database, 'supervisionStories')]
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(event, {})).resolves.toEqual(overview)
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionGraph)!(event, { resultId })).resolves.toMatchObject({
|
|
sources: [{ id: sourceId, title: 'Source', occurred_at: at }] })
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionStories)!(event, { scope: { kind: 'global' } })).resolves.toEqual({
|
|
stories: [], experiences: [], unassigned: 0, canUndo: false })
|
|
expect(await electronMocks.handlers.get(ipcChannels.supervisionSource)!(event, { sourceId })).toMatchObject({ content: 'Full body' })
|
|
await reader.terminateWorkerForTest()
|
|
for (const [channel, input] of requests) await expect(electronMocks.handlers.get(channel)!(event, input)).rejects.toThrow('backing off')
|
|
for (const read of reads) expect(read).not.toHaveBeenCalled()
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: false })
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionStories)!(event, { scope: { kind: 'global' } })).resolves.toEqual({
|
|
stories: [], experiences: [], unassigned: 0, canUndo: false })
|
|
} finally {
|
|
await harness.dispose(); await storageReaders.get(database)?.close(); storageReaders.delete(database); database.close()
|
|
await rm(directory, { recursive: true, force: true, maxRetries: 10 })
|
|
}
|
|
}, 60_000)
|
|
|
|
it('scopes supervision continuation context to source-linked graph evidence', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const occurredAt = '2026-09-22T10:00:00.000Z'
|
|
database.saveSupervisionResult({
|
|
request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: occurredAt, to: occurredAt } },
|
|
evidence: ['selected', 'unrelated', 'unlinked'].map((id) => ({
|
|
id, sourceType: 'conversation', sourceId: `${id}-conversation`, title: `${id} source`, content: `${id} snapshot`, occurredAt
|
|
})),
|
|
output: {
|
|
summary: 'Unrelated whole-result summary', changeDigest: 'Unrelated global digest', openItems: ['Unrelated open item'],
|
|
entities: ['Atlas', 'Beacon', 'Other'].map((id) => ({
|
|
id, label: id, description: `${id} actual description`, sourceReferenceIds: [id === 'Other' ? 'unrelated' : 'selected']
|
|
})),
|
|
events: [
|
|
{ title: 'Linked decision', description: 'Linked event actual description', occurredAt, eventType: 'decision', entityIds: ['Atlas', 'Beacon'], sourceReferenceIds: ['selected'] },
|
|
{ title: 'Unrelated event', description: 'Unrelated event description', occurredAt, eventType: 'discussion', entityIds: ['Other'], sourceReferenceIds: ['unrelated'] }
|
|
],
|
|
entityChanges: [],
|
|
relations: [
|
|
{ fromEntityId: 'Atlas', toEntityId: 'Beacon', relationType: 'depends-on', reason: 'Linked relation actual reason', sourceReferenceIds: ['selected'] },
|
|
{ fromEntityId: 'Atlas', toEntityId: 'Other', relationType: 'related', reason: 'Unrelated relation reason', sourceReferenceIds: ['unrelated'] }
|
|
]
|
|
}
|
|
})
|
|
const harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
|
|
false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
const resultId = database.listSupervisionResults()[0]!.id
|
|
const graph = database.getSupervisionGraph({ resultId }) as import('../shared/supervision-contracts').SupervisionGraphView
|
|
const getGraph = vi.spyOn(database, 'getSupervisionGraph')
|
|
const handler = electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!
|
|
const sourceId = graph.sources.find((source) => source.title === 'selected source')!.id
|
|
const context = await handler(trustedEvent(harness.webContents), { resultId, sourceId }) as { prompt: string; summary: string; source: unknown }
|
|
expect(getGraph).toHaveBeenCalledWith({ resultId })
|
|
expect(context.source).toEqual({ title: 'selected source', sourceType: 'conversation', sourceId: 'selected-conversation', content: 'selected snapshot', occurredAt })
|
|
for (const evidence of ['Linked decision', 'Linked event actual description', 'Atlas actual description', 'Beacon actual description', 'depends-on', 'Linked relation actual reason']) {
|
|
expect(context.summary).toContain(evidence)
|
|
expect(context.prompt).toContain(evidence)
|
|
}
|
|
expect(context.prompt).toContain('selected snapshot')
|
|
for (const unrelated of ['Unrelated whole-result summary', 'Unrelated global digest', 'Unrelated open item', 'Unrelated event', 'Other actual description', 'Unrelated relation reason', 'unrelated snapshot', 'unlinked snapshot']) {
|
|
expect(context.summary).not.toContain(unrelated)
|
|
expect(context.prompt).not.toContain(unrelated)
|
|
}
|
|
const unlinkedSourceId = graph.sources.find((source) => source.title === 'unlinked source')!.id
|
|
const unlinked = await handler(trustedEvent(harness.webContents), { resultId, sourceId: unlinkedSourceId }) as { prompt: string; summary: string }
|
|
expect(unlinked.summary).toBe('')
|
|
expect(unlinked.prompt).toBe('请基于以下监督回顾继续讨论。\n\n来源:unlinked source\nunlinked snapshot')
|
|
} finally { await harness.dispose(); database.close() }
|
|
})
|
|
|
|
it('routes supervisor model selection through the real direct runtime to a saved connection', async () => {
|
|
const calls: Array<{ model: string; tools?: unknown; temperature?: number }> = []
|
|
const server = createServer(async (request, response) => {
|
|
let body = ''
|
|
for await (const chunk of request) body += String(chunk)
|
|
calls.push(JSON.parse(body))
|
|
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
response.end(`data: ${JSON.stringify({ choices: [{ delta: { content: JSON.stringify({ summary: 'Connection verified', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`)
|
|
})
|
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
const address = server.address() as { port: number }
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const now = Date.now()
|
|
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Review', updatedAt: now },
|
|
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'A saved decision', createdAt: now }] }])
|
|
const harness = createHarness({ runtimeId: 'opencode', capability: 'chat', run: vi.fn() }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
|
|
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
|
|
runtimeFactoryMocks.createModelProfileRuntime.mockImplementation(actual.createModelProfileRuntime)
|
|
electronMocks.fetch.mockImplementation((input, init) => fetch(input, init))
|
|
const profile = { id: crypto.randomUUID(), name: 'Review connection',
|
|
baseUrl: `http://127.0.0.1:${address.port}/v1`, modelName: 'review-selected', protocol: 'openai-chat-completions', authentication: 'none',
|
|
requestHeaders: {}, requestBody: { temperature: 0.25 } }
|
|
const fallback = { ...profile, id: crypto.randomUUID(), modelName: 'app-default' }
|
|
harness.getResolvedSettings.mockResolvedValue({ ...defaultRuntimeSettings, provider: 'opencode', modelProfiles: [profile, fallback], defaultModelProfileId: fallback.id })
|
|
const handler = electronMocks.handlers.get(ipcChannels.supervisionRun)!
|
|
const input = { trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange: {
|
|
from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString()
|
|
} }
|
|
try {
|
|
for (const supervisorModelProfileId of [profile.id, null]) {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId })
|
|
await expect(handler(trustedEvent(harness.webContents), input)).resolves.toMatchObject({ status: 'completed' })
|
|
}
|
|
expect(calls.map(call => call.model)).toEqual(['review-selected', 'app-default'])
|
|
expect(calls.every(call => !call.tools && call.temperature === 0.25)).toBe(true)
|
|
for (const invalid of [undefined, { ...profile, protocol: 'openai-images-generations' }, { ...profile, authentication: 'api-key', apiKey: undefined }]) {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId: profile.id })
|
|
harness.getResolvedSettings.mockResolvedValue({ modelProfiles: invalid ? [invalid, fallback] : [fallback], defaultModelProfileId: fallback.id })
|
|
await expect(handler(trustedEvent(harness.webContents), input)).rejects.toThrow(/不可用|API Key/)
|
|
}
|
|
expect(calls).toHaveLength(2)
|
|
} finally {
|
|
await harness.dispose()
|
|
database.close()
|
|
server.closeAllConnections()
|
|
await new Promise<void>(resolve => server.close(() => resolve()))
|
|
runtimeFactoryMocks.createModelProfileRuntime.mockReset()
|
|
electronMocks.fetch.mockReset()
|
|
}
|
|
})
|
|
|
|
it('requires a directory through project creation IPC while preserving blank-path updates', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
|
|
false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
const event = trustedEvent(harness.webContents)
|
|
const create = electronMocks.handlers.get(ipcChannels.projectsCreate)!
|
|
const input = { name: 'Directory project', description: '', rootPath: '' }
|
|
const initialCount = database.listProjects().length
|
|
for (const rootPath of ['', ' ']) {
|
|
await expect(Promise.resolve().then(() => create(event, { ...input, rootPath }))).rejects.toThrow()
|
|
}
|
|
expect(database.listProjects()).toHaveLength(initialCount)
|
|
const created = await create(event, { ...input, rootPath: ` ${process.cwd()} ` }) as AssistantProject
|
|
expect(database.getProject(created.id).rootPath).toBe(process.cwd())
|
|
database.updateProject(created.id, input)
|
|
await electronMocks.handlers.get(ipcChannels.projectsUpdate)!(event, {
|
|
projectId: created.id, input: { ...input, name: 'Renamed historical project' }
|
|
})
|
|
expect(database.getProject(created.id)).toMatchObject({ name: 'Renamed historical project', rootPath: '' })
|
|
} finally {
|
|
await harness.dispose()
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it.each(['global', 'projects'] as const)('collects supervision within the exact UI timeRange before limits (%s)', async (kind) => {
|
|
vi.useFakeTimers({ toFake: ['Date'] })
|
|
vi.setSystemTime(new Date('2026-09-22T08:00:00.000Z'))
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const project = database.createProject({ name: 'Review', description: '', rootPath: process.cwd() })
|
|
const conversationId = crypto.randomUUID()
|
|
const from = '2026-09-22T10:00:00.000Z'
|
|
const to = '2026-09-22T10:15:00.000Z'
|
|
const after = '2026-09-22T10:15:00.001Z'
|
|
const oldTask = database.createTask({ id: crypto.randomUUID(), projectId: project.id, title: 'Old completed task', instructions: 'Review' })
|
|
const memory = database.createMemory({ scope: 'global', type: 'fact', content: 'Current background content' })
|
|
vi.setSystemTime(new Date(from))
|
|
const newTask = database.createTask({ id: crypto.randomUUID(), projectId: project.id, title: 'Created in range', instructions: 'Review' })
|
|
vi.setSystemTime(new Date(to))
|
|
database.updateTaskStatus(oldTask.id, 'completed')
|
|
vi.setSystemTime(new Date(after))
|
|
database.updateTaskStatus(newTask.id, 'completed')
|
|
database.createTask({ id: crypto.randomUUID(), projectId: project.id, title: 'Future task', instructions: 'Review' })
|
|
database.setMemoryStatus(memory.id, 'confirmed')
|
|
const message = (content: string, timestamp: string) => ({ id: crypto.randomUUID(), role: 'user' as const, state: 'complete' as const, content, createdAt: Date.parse(timestamp) })
|
|
database.saveLocalConversations([{
|
|
header: { id: conversationId, projectId: project.id, title: 'Review', updatedAt: Date.parse(after) },
|
|
messages: [message('Before range', '2026-09-22T09:59:59.999Z'), message('At start', from), message('At end', to),
|
|
...Array.from({ length: 25 }, () => message('After range', after))]
|
|
}, ...Array.from({ length: 21 }, () => ({
|
|
header: { id: crypto.randomUUID(), projectId: project.id, title: 'Future-only conversation', updatedAt: Date.parse(after) + 1 },
|
|
messages: [message('Future-only message', after)]
|
|
}))])
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
|
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify({ summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
vi.setSystemTime(new Date('2026-09-23T12:00:00.000Z'))
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run }, undefined, undefined, false, undefined, undefined, undefined,
|
|
false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
const scope = kind === 'global' ? { kind } : { kind, projectIds: [project.id] }
|
|
const input = { trigger: 'manual', scope, timeRange: { from, to } }
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
|
|
const result = await electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), input) as import('./assistant/supervisor-service').StoredSupervisionResult
|
|
expect(result.request).toEqual(input)
|
|
const batches = await electronMocks.handlers.get(ipcChannels.supervisionBatches)!(trustedEvent(harness.webContents), { runId: result.runId }) as import('../shared/supervision-review-contracts').SupervisionReviewBatch[]
|
|
const evidence = batches.flatMap(batch => batch.evidence)
|
|
expect(result.coverage).toMatchObject({ complete: true, remainingSources: 0, sources: 4 })
|
|
expect(evidence.filter(item => item.sourceType === 'conversation').map(item => item.content)).toEqual(['At start', 'At end'])
|
|
expect(evidence.filter(item => item.sourceType === 'task')).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ sourceId: oldTask.id, occurredAt: to }),
|
|
expect.objectContaining({ sourceId: newTask.id, occurredAt: from })
|
|
]))
|
|
expect(evidence.filter(item => item.sourceType === 'task')).toHaveLength(2)
|
|
expect(run.mock.calls[0]![0].prompt).toContain(memory.content)
|
|
expect(run.mock.calls[0]![0].prompt).toContain(JSON.stringify(input.timeRange))
|
|
expect(run.mock.calls[0]![0].prompt).toContain('not a historical content snapshot')
|
|
const overview = await electronMocks.handlers.get(ipcChannels.supervisionOverview)!(trustedEvent(harness.webContents), { target: { type: 'conversation', conversationId } }) as Array<{ id: string; storyLineId: string; sourceId: string }>
|
|
expect(overview).toHaveLength(1)
|
|
const activity = electronMocks.handlers.get(ipcChannels.supervisionActivity)!
|
|
await expect(activity(trustedEvent(harness.webContents), { limit: 50, offset: 0 })).resolves.toEqual([
|
|
expect.objectContaining({ status: 'completed', resultId: overview[0]!.id, scope, trigger: 'manual' })
|
|
])
|
|
await expect(activity(trustedEvent(harness.webContents), { limit: 101 })).rejects.toThrow()
|
|
await expect(activity(trustedEvent(harness.webContents), { configId: 'unrelated-plan' })).resolves.toEqual([])
|
|
await expect(activity(trustedEvent(harness.webContents), { configId: '' })).rejects.toThrow()
|
|
await expect(activity({ sender: {} }, {})).rejects.toThrow()
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id })).resolves.toEqual(overview)
|
|
expect(database.getSupervisionSource(overview[0]!.sourceId)?.sourceId).toBe(conversationId)
|
|
const graph = await electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: overview[0]!.storyLineId }) as { sources: unknown[] }
|
|
expect(graph.sources).toHaveLength(evidence.length)
|
|
expect(graph).toHaveProperty('attention', [{ start: from, turns: 2, characters: 14 }])
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).rejects.toThrow('不匹配')
|
|
await expect(Promise.resolve().then(() => electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!(trustedEvent(harness.webContents), { resultId: 'wrong-result', sourceId: overview[0]!.sourceId }))).rejects.toThrow('不匹配')
|
|
const legacy = database.buildHeartbeatInput({ scope, lookbackHours: 1 }, new Date(to))
|
|
expect(legacy.tasks.some(task => task.id === oldTask.id)).toBe(false)
|
|
expect(legacy.tasks.some(task => task.title === 'Future task')).toBe(true)
|
|
database.setProjectArchived(project.id, true)
|
|
await expect(electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), {
|
|
...input, scope: { kind: 'projects', projectIds: [project.id] }
|
|
})).rejects.toThrow('must exist and be active')
|
|
expect(run).toHaveBeenCalledTimes(3)
|
|
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
|
|
})
|
|
|
|
it.each([
|
|
['throw', undefined], ['return', undefined], ['incomplete', undefined],
|
|
['throw', 30], ['return', 600], ['provider', 30], ['cleanup', 30]
|
|
] as const)('rejects supervision timeout or incomplete streams without persisting (%s, %s)', async (ending, configuredTimeout) => {
|
|
vi.useFakeTimers()
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Evidence', updatedAt: Date.now() },
|
|
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this decision', createdAt: Date.now() }] }])
|
|
let first = true
|
|
let providerFailures = ending === 'provider' ? 2 : 0
|
|
const releaseConversation = vi.fn(async () => undefined)
|
|
if (ending === 'cleanup') releaseConversation.mockRejectedValueOnce(new Error('Release failed'))
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest, signal: AbortSignal) {
|
|
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify({ summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
|
|
if (providerFailures > 0) {
|
|
providerFailures--
|
|
first = false
|
|
throw new Error('Provider HTTP 429: retry later')
|
|
}
|
|
if (first) {
|
|
first = false
|
|
if (ending === 'incomplete') return
|
|
if (ending !== 'cleanup') await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
|
|
if (ending === 'throw') throw new DOMException('This operation was aborted', 'AbortError')
|
|
}
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, heartbeatReportTimeoutSeconds: 60, supervisorOrganizeTimeoutSeconds: configuredTimeout })
|
|
const input = { trigger: 'manual', scope: { kind: 'global' }, timeRange: {
|
|
from: new Date(Date.now() - 1000).toISOString(), to: new Date().toISOString()
|
|
} }
|
|
const invoke = () => electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), input)
|
|
const timeoutSeconds = configuredTimeout ?? 240
|
|
const error = ending === 'incomplete' ? '监督者模型未报告完成' : ending === 'provider' ? 'Provider HTTP 429: retry later' : ending === 'cleanup' ? 'Release failed' : `监督者整理模型阶段超过 ${timeoutSeconds} 秒,已停止本次回顾;未保存结果`
|
|
const rejected = expect(invoke()).rejects.toThrow(error)
|
|
await vi.advanceTimersByTimeAsync(timeoutSeconds * 1000 - 1)
|
|
if (ending === 'throw' || ending === 'return') expect(database.listSupervisionActivity()[0]?.status).toBe('running')
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
await rejected
|
|
expect(database.listSupervisionResults()).toEqual([])
|
|
expect(database.listSupervisionActivity()).toEqual([expect.objectContaining({ status: 'failed', error })])
|
|
expect(releaseConversation).toHaveBeenCalledTimes(ending === 'provider' ? 2 : 1)
|
|
await invoke()
|
|
expect(database.listSupervisionResults()).toHaveLength(1)
|
|
expect(releaseConversation).toHaveBeenCalledTimes(ending === 'provider' ? 3 : 2)
|
|
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
|
|
})
|
|
|
|
it('skips unchanged sources for heartbeat and default manual reviews while explicit reanalysis reprocesses history', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const now = Date.now()
|
|
const conversationId = crypto.randomUUID()
|
|
const messageId = crypto.randomUUID()
|
|
const write = (content: string) => database.saveLocalConversations([{
|
|
header: { id: conversationId, projectId: database.listProjects()[0]!.id, title: 'Atlas', updatedAt: now },
|
|
messages: [{ id: messageId, role: 'user', state: 'complete', createdAt: now, content }]
|
|
}])
|
|
write('Atlas uses SQLite')
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
|
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify({ summary: 'Atlas reviewed', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run }, undefined, undefined, false, undefined, undefined, undefined,
|
|
false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
|
|
const input = { trigger: 'heartbeat', scope: { kind: 'global' }, timeRange: {
|
|
from: new Date(now - 1000).toISOString(), to: new Date(now + 1000).toISOString()
|
|
} }
|
|
const invoke = (request: Record<string, unknown> = input) => electronMocks.handlers.get(ipcChannels.supervisionRun)!(trustedEvent(harness.webContents), request)
|
|
await invoke()
|
|
await expect(invoke()).resolves.toMatchObject({ status: 'no_change', evidence: [] })
|
|
expect(run).toHaveBeenCalledTimes(1)
|
|
write('Atlas now uses WAL')
|
|
await invoke()
|
|
expect(run).toHaveBeenCalledTimes(2)
|
|
expect(run.mock.calls[1]![0].prompt).toContain('Atlas now uses WAL')
|
|
await expect(invoke({ ...input, trigger: 'manual' })).resolves.toMatchObject({ status: 'no_change' })
|
|
expect(run).toHaveBeenCalledTimes(2)
|
|
await invoke({ ...input, trigger: 'manual', reanalyze: true })
|
|
await invoke({ ...input, trigger: 'manual', reanalyze: true })
|
|
expect(run).toHaveBeenCalledTimes(4)
|
|
// Re-analysis never moves shared progress, so the heartbeat still sees nothing new.
|
|
await expect(invoke()).resolves.toMatchObject({ status: 'no_change' })
|
|
expect(database.listSupervisionResults()).toHaveLength(4)
|
|
expect(database.listSupervisionActivity().filter((row) => row.status === 'no_change')).toHaveLength(3)
|
|
} finally { await harness.dispose(); database.close() }
|
|
})
|
|
|
|
it('dispatches supervision continuation through the real queue payload parser', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const conversationId = crypto.randomUUID()
|
|
database.saveLocalConversations([{ header: { id: conversationId, title: 'Continue', updatedAt: Date.now(), runtimeSelection: { provider: 'opencode' } }, messages: [] }])
|
|
const harness = createHarness({ capability: 'chat' }, undefined, undefined, false, undefined, undefined, undefined,
|
|
false, undefined, undefined, undefined, undefined, database)
|
|
try {
|
|
await electronMocks.handlers.get(ipcChannels.supervisionContinue)!(trustedEvent(harness.webContents), { conversationId, prompt: 'Continue this review' })
|
|
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.conversationQueueDispatch,
|
|
expect.objectContaining({ input: expect.objectContaining({ conversationId, prompt: 'Continue this review', attachments: [] }) })))
|
|
const dispatch = harness.webContents.send.mock.calls.find(([channel]) => channel === ipcChannels.conversationQueueDispatch)![1] as ConversationQueueDispatch
|
|
expect(database.isConversationUserQueueItemDispatching(dispatch.item.id)).toBe(true)
|
|
expect(harness.contextManager.restoreFromQueue).not.toHaveBeenCalled()
|
|
} finally { await harness.dispose(); database.close() }
|
|
})
|
|
|
|
it('validates supervision knowledge preview and commit against actual local entity ownership', async () => {
|
|
const database = new KnowledgeDatabase(':memory:')
|
|
database.initialize()
|
|
const library = database.createKnowledgeBase({ name: 'Target', storageMode: 'reference' })
|
|
const other = database.createKnowledgeBase({ name: 'Other', storageMode: 'reference' })
|
|
const entity = database.createEntity({ knowledgeBaseId: other.id, name: 'Original', type: 'Concept' })
|
|
const harness = createHarness({ capability: 'chat' }, undefined, undefined, false, undefined, { database })
|
|
Object.assign(harness.assistantDatabase, { getSupervisionSource: () => ({ id: 'source', title: 'Source', content: 'Evidence' }) })
|
|
const event = trustedEvent(harness.webContents)
|
|
const preview = electronMocks.handlers.get(ipcChannels.supervisionKnowledgePreview)!
|
|
const commit = electronMocks.handlers.get(ipcChannels.supervisionKnowledgeCommit)!
|
|
const input = { operation: 'update-entity', libraryId: library.id, entityId: entity.id, label: 'Revised', type: 'Concept', description: 'Preview description', aliases: ['Alias'], sourceId: 'source' }
|
|
try {
|
|
await expect(Promise.resolve().then(() => preview(event, input))).rejects.toThrow('不属于所选知识库')
|
|
await expect(Promise.resolve().then(() => preview(event, { ...input, entityId: crypto.randomUUID() }))).rejects.toThrow('不属于所选知识库')
|
|
await expect(Promise.resolve().then(() => preview(event, { ...input, operation: 'create-entity', libraryId: crypto.randomUUID() }))).rejects.toThrow('知识库不存在')
|
|
const binding = vi.spyOn(database.externalStore, 'hasBinding').mockReturnValue(true)
|
|
await expect(Promise.resolve().then(() => preview(event, { ...input, operation: 'create-entity' }))).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
|
|
binding.mockReturnValue(false)
|
|
const valid = { ...input, libraryId: other.id }
|
|
const first = await preview(event, valid) as { previewId: string }
|
|
expect(first).toMatchObject({ entity: valid, source: { content: 'Evidence' } })
|
|
binding.mockReturnValue(true)
|
|
await expect(Promise.resolve().then(() => commit(event, { previewId: first.previewId }))).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
|
|
expect(database.getEntity(entity.id)?.name).toBe('Original')
|
|
binding.mockReturnValue(false)
|
|
const second = await preview(event, valid) as { previewId: string }
|
|
const getEntity = vi.spyOn(database, 'getEntity').mockReturnValue({ ...entity, knowledgeBaseId: library.id })
|
|
await expect(Promise.resolve().then(() => commit(event, { previewId: second.previewId }))).rejects.toThrow('不属于所选知识库')
|
|
getEntity.mockRestore()
|
|
const third = await preview(event, valid) as { previewId: string }
|
|
await commit(event, { previewId: third.previewId })
|
|
expect(database.getEntity(entity.id)).toMatchObject({ name: 'Revised', description: 'Preview description', locked: true })
|
|
const create = await preview(event, { ...input, operation: 'create-entity', entityId: undefined }) as { previewId: string }
|
|
await commit(event, { previewId: create.previewId })
|
|
expect(database.listEntities(library.id)).toEqual([expect.objectContaining({ name: 'Revised', locked: true })])
|
|
} finally { vi.restoreAllMocks(); await harness.dispose(); database.close() }
|
|
})
|
|
|
|
it.each(['completed', 'failed'] as const)('creates an immediate task into the production queue and dispatches exactly one ordinary run (%s)', async (status) => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const conversationId = crypto.randomUUID()
|
|
let timingNow = Date.now()
|
|
const timingClock = vi.spyOn(Date, 'now').mockImplementation(() => timingNow)
|
|
database.saveLocalConversations([{
|
|
header: { id: conversationId, title: 'Immediate', updatedAt: Date.now() },
|
|
messages: []
|
|
}])
|
|
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
|
timingNow += 3000
|
|
if (status === 'failed') {
|
|
yield { type: 'error' as const, requestId: request.requestId, message: 'Runtime failed' }
|
|
return
|
|
}
|
|
yield { type: 'text' as const, requestId: request.requestId, delta: 'Once' }
|
|
yield { type: 'done' as const, requestId: request.requestId }
|
|
})
|
|
const harness = createHarness(
|
|
{ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run },
|
|
undefined, undefined, false, undefined, undefined, undefined,
|
|
false, undefined, undefined, undefined, undefined, database
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
try {
|
|
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
|
|
const create = electronMocks.handlers.get(ipcChannels.schedulesCreate)!
|
|
const input = {
|
|
conversationId, title: 'Immediate', prompt: 'Run once', recurrence: 'once',
|
|
nextRunAt: '2020-01-01T00:00:00.000Z'
|
|
}
|
|
await expect(Promise.resolve().then(() => create(event, input))).rejects.toThrow('首次运行时间必须晚于当前时间')
|
|
expect(database.listSchedules()).toEqual([])
|
|
const schedule = await create(event, { ...input, runImmediately: true }) as AssistantSchedule
|
|
expect(schedule.enabled).toBe(false)
|
|
const dispatches = () => harness.webContents.send.mock.calls.filter(
|
|
([channel]) => channel === ipcChannels.conversationQueueDispatch
|
|
)
|
|
await vi.waitFor(() => expect(dispatches()).toHaveLength(1))
|
|
const dispatch = dispatches()[0]![1] as ConversationQueueDispatch
|
|
expect(dispatch).toMatchObject({ scheduled: true, item: { taskId: schedule.taskId } })
|
|
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
|
|
await harness.handler!(event, {
|
|
requestId: dispatch.item.id,
|
|
conversationId,
|
|
queueItemId: dispatch.item.id,
|
|
prompt: input.prompt,
|
|
currentUserMessageId: crypto.randomUUID(),
|
|
currentAssistantMessageId: crypto.randomUUID()
|
|
})
|
|
await vi.waitFor(() => expect(database.getTask(schedule.taskId).status).toBe(status))
|
|
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
|
|
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
|
|
expect(dispatches()).toHaveLength(1)
|
|
expect(run).toHaveBeenCalledOnce()
|
|
expect(database.listConversationQueueItems()).toEqual([])
|
|
expect(database.listSchedules()).toHaveLength(1)
|
|
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 3000, runningCount: 0, incomplete: false })
|
|
} finally {
|
|
await harness.dispose()
|
|
timingClock.mockRestore()
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('dispatches scheduled messages through ordinary agent runs with current history and tools', async () => {
|
|
const fullOutput = `${'x'.repeat(1_000_001)} scheduled tail`
|
|
const taskId = '00000000-0000-4000-8000-000000000701'
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000702'
|
|
const scheduleId =
|
|
'00000000-0000-4000-8000-000000000703'
|
|
const runId = '00000000-0000-4000-8000-000000000704'
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(
|
|
request: { requestId: string; history?: unknown },
|
|
_signal: AbortSignal,
|
|
authorize: (
|
|
input: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}
|
|
) => Promise<string>
|
|
) {
|
|
expect(request.requestId).toBe(runId)
|
|
expect(request.history).toEqual([{ role: 'user', content: 'Earlier conversation' }])
|
|
expect(request).not.toHaveProperty('workMode')
|
|
expect(authorize).toBeUndefined()
|
|
yield { requestId: request.requestId, type: 'tool', callId: 'scheduled-write',
|
|
name: 'workspace.write', state: 'completed', summary: 'Updated status file' } as const
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: fullOutput
|
|
} as const
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)?.(
|
|
trustedEvent(harness.webContents), conversationId)
|
|
const schedule = {
|
|
id: scheduleId,
|
|
projectId: '00000000-0000-4000-8000-000000000401',
|
|
taskId,
|
|
conversationId,
|
|
title: '每日状态',
|
|
prompt: '汇总状态',
|
|
recurrence: 'daily' as const,
|
|
nextRunAt: '2026-08-20T00:00:00.000Z',
|
|
enabled: true,
|
|
createdAt: '2026-08-19T00:00:00.000Z',
|
|
updatedAt: '2026-08-19T00:00:00.000Z'
|
|
}
|
|
const queueItem = {
|
|
id: runId,
|
|
conversationId,
|
|
source: 'schedule' as const,
|
|
label: schedule.title,
|
|
scheduleRunId: runId,
|
|
scheduleId,
|
|
taskId,
|
|
createdAt: '2026-08-19T00:01:00.000Z'
|
|
}
|
|
harness.assistantDatabase.queueScheduleNow.mockReturnValue(
|
|
queueItem
|
|
)
|
|
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(queueItem)
|
|
harness.assistantDatabase.listConversationQueueItems
|
|
.mockReturnValueOnce([queueItem])
|
|
.mockReturnValue([])
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValue({
|
|
source: 'schedule',
|
|
item: queueItem,
|
|
schedule,
|
|
runId
|
|
})
|
|
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.schedulesRunNow
|
|
)?.(trustedEvent(harness.webContents), scheduleId)
|
|
|
|
expect(harness.assistantDatabase.createTask).not.toHaveBeenCalled()
|
|
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch,
|
|
{ item: queueItem, scheduled: true, input: {
|
|
conversationId, projectId: schedule.projectId, prompt: schedule.prompt
|
|
} }))
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: runId, conversationId, queueItemId: queueItem.id,
|
|
prompt: schedule.prompt, history: [{ role: 'user', content: 'Earlier conversation' }]
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.completeTaskScheduleRun
|
|
).toHaveBeenCalledWith(runId)
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(taskId, 'running')
|
|
expect(
|
|
harness.assistantDatabase.appendConversationMessage
|
|
).not.toHaveBeenCalled()
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent, expect.objectContaining({
|
|
requestId: runId, type: 'text', delta: fullOutput
|
|
}))
|
|
expect(
|
|
harness.assistantDatabase.createTextArtifact
|
|
).not.toHaveBeenCalled()
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationsChanged
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('allows scheduled messages to ask questions through the ordinary conversation UI', async () => {
|
|
const taskId = '00000000-0000-4000-8000-000000000711'
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000712'
|
|
const scheduleId =
|
|
'00000000-0000-4000-8000-000000000713'
|
|
const runId = '00000000-0000-4000-8000-000000000714'
|
|
const respondToQuestion = vi.fn().mockResolvedValue(undefined)
|
|
const runtime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
respondToQuestion,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'question',
|
|
questionId: 'opencode-background-question',
|
|
questions: [
|
|
{
|
|
header: 'Choice',
|
|
question: 'Choose an implementation',
|
|
options: [],
|
|
multiple: false,
|
|
custom: true
|
|
}
|
|
]
|
|
} as const
|
|
yield { requestId: request.requestId, type: 'done' } as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)?.(
|
|
trustedEvent(harness.webContents), conversationId)
|
|
const schedule = {
|
|
id: scheduleId,
|
|
projectId: '00000000-0000-4000-8000-000000000401',
|
|
taskId,
|
|
conversationId,
|
|
title: '后台提问测试',
|
|
prompt: '执行任务',
|
|
recurrence: 'daily' as const,
|
|
nextRunAt: '2026-08-20T00:00:00.000Z',
|
|
enabled: true,
|
|
createdAt: '2026-08-19T00:00:00.000Z',
|
|
updatedAt: '2026-08-19T00:00:00.000Z'
|
|
}
|
|
const queueItem = {
|
|
id: runId,
|
|
conversationId,
|
|
source: 'schedule' as const,
|
|
label: schedule.title,
|
|
scheduleRunId: runId,
|
|
scheduleId,
|
|
taskId,
|
|
createdAt: '2026-08-19T00:01:00.000Z'
|
|
}
|
|
harness.assistantDatabase.queueScheduleNow.mockReturnValue(
|
|
queueItem
|
|
)
|
|
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(queueItem)
|
|
harness.assistantDatabase.listConversationQueueItems
|
|
.mockReturnValueOnce([queueItem])
|
|
.mockReturnValue([])
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValue({
|
|
source: 'schedule',
|
|
item: queueItem,
|
|
schedule,
|
|
runId
|
|
})
|
|
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.schedulesRunNow
|
|
)?.(trustedEvent(harness.webContents), scheduleId)
|
|
|
|
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch, expect.objectContaining({ item: queueItem })
|
|
))
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: runId, conversationId, queueItemId: queueItem.id,
|
|
prompt: schedule.prompt
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.completeTaskScheduleRun
|
|
).toHaveBeenCalledWith(runId)
|
|
)
|
|
expect(respondToQuestion).not.toHaveBeenCalled()
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent, expect.objectContaining({
|
|
type: 'question', questionId: 'opencode-background-question'
|
|
}))
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('serializes Agent runs that target the same Conversation', async () => {
|
|
let finishRun: (() => void) | undefined
|
|
const runtimeStarted = vi.fn()
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) {
|
|
runtimeStarted(request.requestId)
|
|
await new Promise<void>((resolve) => {
|
|
finishRun = resolve
|
|
})
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000721'
|
|
const firstRun = harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
{
|
|
requestId: '00000000-0000-4000-8000-000000000722',
|
|
conversationId,
|
|
prompt: '第一条',
|
|
knowledgeLibraryIds: []
|
|
}
|
|
)
|
|
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
|
|
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000723',
|
|
conversationId,
|
|
prompt: '第二条',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
).rejects.toThrow('当前对话已有执行中的请求')
|
|
|
|
finishRun?.()
|
|
await firstRun
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(
|
|
'00000000-0000-4000-8000-000000000722',
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000724',
|
|
conversationId,
|
|
prompt: '第三条',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
).resolves.toBeUndefined()
|
|
await vi.waitFor(() =>
|
|
expect(runtimeStarted).toHaveBeenCalledTimes(2)
|
|
)
|
|
finishRun?.()
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(
|
|
'00000000-0000-4000-8000-000000000724',
|
|
'completed'
|
|
)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('keeps the active lease through settings reload and releases it only after cancellation settles', async () => {
|
|
const controls = new Map<
|
|
string,
|
|
{
|
|
finish: () => void
|
|
signal: AbortSignal
|
|
}
|
|
>()
|
|
const runtimeStarted = vi.fn()
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(
|
|
request: { conversationId: string; requestId: string },
|
|
signal: AbortSignal
|
|
) {
|
|
runtimeStarted(request.conversationId)
|
|
await new Promise<void>((resolve) => {
|
|
controls.set(request.conversationId, {
|
|
finish: resolve,
|
|
signal
|
|
})
|
|
})
|
|
signal.throwIfAborted()
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const goodbuddyConfigService = {
|
|
takePendingReload: vi.fn(() => 'none'),
|
|
revokeRequest: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'lease-race-capability'),
|
|
getAvailableToolNames: vi.fn(() => []),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
false,
|
|
goodbuddyConfigService
|
|
)
|
|
const requestId = '00000000-0000-4000-8000-000000000741'
|
|
const oldConversationId =
|
|
'00000000-0000-4000-8000-000000000742'
|
|
const newConversationId =
|
|
'00000000-0000-4000-8000-000000000743'
|
|
const event = trustedEvent(harness.webContents)
|
|
|
|
await harness.handler?.(event, {
|
|
requestId,
|
|
conversationId: oldConversationId,
|
|
prompt: '旧请求',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(controls.has(oldConversationId)).toBe(true)
|
|
)
|
|
|
|
await electronMocks.handlers.get(
|
|
ipcChannels.runtimeCustomizationUpdate
|
|
)?.(event, {
|
|
opencode: {},
|
|
continue: { presets: [] }
|
|
})
|
|
expect(controls.get(oldConversationId)?.signal.aborted).toBe(false)
|
|
|
|
await expect(harness.handler?.(event, {
|
|
requestId,
|
|
conversationId: newConversationId,
|
|
prompt: '替换请求',
|
|
knowledgeLibraryIds: []
|
|
})).rejects.toThrow('请求正在执行')
|
|
|
|
harness.cancelHandler?.(event, requestId)
|
|
expect(controls.get(oldConversationId)?.signal.aborted).toBe(true)
|
|
controls.get(oldConversationId)?.finish()
|
|
await vi.waitFor(() =>
|
|
expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce()
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
|
|
|
await harness.handler?.(event, {
|
|
requestId,
|
|
conversationId: newConversationId,
|
|
prompt: '替换请求',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(controls.has(newConversationId)).toBe(true)
|
|
)
|
|
harness.cancelHandler?.(event, requestId)
|
|
expect(controls.get(newConversationId)?.signal.aborted).toBe(true)
|
|
await expect(
|
|
harness.handler?.(event, {
|
|
requestId: '00000000-0000-4000-8000-000000000744',
|
|
conversationId: newConversationId,
|
|
prompt: '不能并发',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
).rejects.toThrow('当前对话已有执行中的请求')
|
|
|
|
controls.get(newConversationId)?.finish()
|
|
await vi.waitFor(() => {
|
|
const cancellations =
|
|
harness.assistantDatabase.updateTaskStatus.mock.calls.filter(
|
|
([id, status]) =>
|
|
id === requestId && status === 'cancelled'
|
|
)
|
|
expect(cancellations).toHaveLength(2)
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledTimes(2)
|
|
)
|
|
|
|
await harness.handler?.(event, {
|
|
requestId,
|
|
conversationId: newConversationId,
|
|
prompt: '清理后重试',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(runtimeStarted).toHaveBeenCalledTimes(3)
|
|
)
|
|
controls.get(newConversationId)?.finish()
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each([false, true])('owns a dedicated tool-free synthesis runtime and disposes it on failure=%s', async (fail) => {
|
|
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
|
|
runtimeFactoryMocks.createDefaultModelRuntime.mockImplementation(actual.createDefaultModelRuntime)
|
|
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
|
|
const body = JSON.parse(String(init?.body))
|
|
expect(body.tools).toBeUndefined()
|
|
expect(body.tool_choice).toBeUndefined()
|
|
return new Response('data: {"choices":[{"delta":{"content":"Combined answer"},"finish_reason":null}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
|
|
{ headers: { 'content-type': 'text/event-stream' } })
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
let disposeSynthesis: ReturnType<typeof vi.spyOn> | undefined
|
|
const subagents = {
|
|
run: vi.fn<SubagentService['run']>(async () => ({ childTaskId: crypto.randomUUID(), output: 'Specialist result' })),
|
|
synthesize: vi.fn(async (...args: Parameters<SubagentService['synthesize']>) => {
|
|
const [request, prompt, signal, synthesisRuntime, onModelUsage] = args
|
|
expect(prompt).toContain('Specialist result')
|
|
disposeSynthesis = vi.spyOn(synthesisRuntime, 'dispose')
|
|
const events = []
|
|
for await (const event of synthesisRuntime.run({ ...request, prompt }, signal)) events.push(event)
|
|
expect(events).toContainEqual(expect.objectContaining({ type: 'text', delta: 'Combined answer' }))
|
|
expect(events).toContainEqual(expect.objectContaining({ type: 'done' }))
|
|
onModelUsage!({ requestId: args[0].requestId, type: 'model-usage', callId: 'synthesis-usage',
|
|
runtime: 'model', provider: 'openai', model: 'test-model', inputTokens: 10, outputTokens: 5,
|
|
cacheReadTokens: 0, cacheWriteTokens: 0 })
|
|
if (fail) throw new Error('Synthesis failed')
|
|
return 'Combined answer'
|
|
}),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const runtime = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run: vi.fn() }
|
|
const harness = createHarness(runtime, undefined, subagents)
|
|
const settings = { ...runtimeUpdateFixture(process.cwd()).publicSettings, modelAuthentication: 'none' as const }
|
|
harness.getResolvedSettings.mockResolvedValue(settings)
|
|
harness.assistantDatabase.listExperts.mockReturnValue([
|
|
{ id: crypto.randomUUID(), name: 'First', enabled: true },
|
|
{ id: crypto.randomUUID(), name: 'Second', enabled: true }
|
|
])
|
|
const requestId = crypto.randomUUID()
|
|
try {
|
|
await harness.handler!(trustedEvent(harness.webContents), {
|
|
requestId, conversationId: 'synthesis-test', prompt: 'Compare the analyses', teamMode: true
|
|
})
|
|
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId, fail ? 'failed' : 'completed', ...(fail ? ['Synthesis failed'] : [])))
|
|
expect(subagents.run).toHaveBeenCalledTimes(2)
|
|
for (const [input] of subagents.run.mock.calls) {
|
|
expect(input).not.toHaveProperty('authorize')
|
|
}
|
|
expect(runtime.run).not.toHaveBeenCalled()
|
|
expect(runtimeFactoryMocks.createDefaultModelRuntime).toHaveBeenCalledExactlyOnceWith(process.cwd(), settings)
|
|
expect(subagents.synthesize).toHaveBeenCalledOnce()
|
|
expect(fetcher).toHaveBeenCalledOnce()
|
|
expect(harness.assistantDatabase.upsertModelUsageCall).toHaveBeenCalledWith(
|
|
expect.objectContaining({ callId: 'synthesis-usage' }))
|
|
expect(disposeSynthesis).toHaveBeenCalledOnce()
|
|
} finally {
|
|
await harness.dispose()
|
|
runtimeFactoryMocks.createDefaultModelRuntime.mockReset()
|
|
vi.unstubAllGlobals()
|
|
}
|
|
})
|
|
|
|
it('cancels only an expert-team parent on settings replacement without synthesizing on the replacement model', async () => {
|
|
let rejectExpert!: (error: Error) => void
|
|
let parentSignal: AbortSignal | undefined
|
|
const subagents = {
|
|
run: vi.fn(async (input: { expert: { name: string }; signal: AbortSignal }) => {
|
|
parentSignal = input.signal
|
|
if (input.expert.name === 'First') return { childTaskId: 'first', output: 'Finished analysis' }
|
|
return new Promise((_, reject) => { rejectExpert = reject })
|
|
}),
|
|
synthesize: vi.fn(async () => 'Must not use replacement model'),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
let finishPeer!: () => void
|
|
let peerSignal: AbortSignal | undefined
|
|
const runtime = {
|
|
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
|
|
async *run(request: { requestId: string }, signal: AbortSignal) {
|
|
peerSignal = signal
|
|
await new Promise<void>(resolve => { finishPeer = resolve })
|
|
yield { requestId: request.requestId, type: 'done' as const }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime, undefined, subagents)
|
|
harness.assistantDatabase.listExperts.mockReturnValue([
|
|
{ id: '00000000-0000-4000-8000-000000000781', name: 'First', enabled: true },
|
|
{ id: '00000000-0000-4000-8000-000000000782', name: 'Second', enabled: true }
|
|
])
|
|
harness.onRuntimeSettingsChanged.mockImplementation(async () => {
|
|
rejectExpert(new Error('默认模型设置已更改'))
|
|
})
|
|
const event = trustedEvent(harness.webContents)
|
|
const teamId = '00000000-0000-4000-8000-000000000783'
|
|
try {
|
|
await harness.handler?.(event, {
|
|
requestId: teamId, conversationId: 'expert-team',
|
|
prompt: 'Analyze with the team', teamMode: true, knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() => expect(subagents.run).toHaveBeenCalledTimes(2))
|
|
await harness.handler?.(event, {
|
|
requestId: '00000000-0000-4000-8000-000000000784', conversationId: 'ordinary-peer',
|
|
prompt: 'Keep working', knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() => expect(peerSignal).toBeDefined())
|
|
await electronMocks.handlers.get(ipcChannels.runtimeCustomizationUpdate)!(event, {
|
|
opencode: {}, continue: { presets: [] }
|
|
})
|
|
expect(parentSignal?.aborted).toBe(true)
|
|
expect(peerSignal?.aborted).toBe(false)
|
|
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus)
|
|
.toHaveBeenCalledWith(teamId, 'cancelled', '请求已取消'))
|
|
expect(subagents.synthesize).not.toHaveBeenCalled()
|
|
} finally {
|
|
rejectExpert?.(new Error('Test cleanup'))
|
|
finishPeer?.()
|
|
await harness.dispose()
|
|
}
|
|
})
|
|
|
|
it('detaches an accepted managed SSH run without aborting it or waiting for completion', async () => {
|
|
let finishRun = (): void => undefined
|
|
let remoteSignal: AbortSignal | undefined
|
|
let statusConsumed = false
|
|
const remoteRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(
|
|
request: { requestId: string },
|
|
signal: AbortSignal
|
|
) {
|
|
remoteSignal = signal
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'status',
|
|
message: '远端 ACP Runtime 正在处理请求'
|
|
} as const
|
|
statusConsumed = true
|
|
await new Promise<void>((resolve) => {
|
|
finishRun = resolve
|
|
})
|
|
signal.throwIfAborted()
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => remoteRuntime)
|
|
}
|
|
const harness = createHarness(
|
|
remoteRuntime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
const projectId = '00000000-0000-4000-8000-000000000751'
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
id: projectId,
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000752',
|
|
remoteRootPath: '/srv/project'
|
|
}
|
|
})
|
|
const requestId = '00000000-0000-4000-8000-000000000753'
|
|
const run = harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
{
|
|
requestId,
|
|
conversationId: 'managed-ssh-conversation',
|
|
projectId,
|
|
runtimeSelection: { provider: 'opencode' },
|
|
prompt: 'continue on Agent',
|
|
knowledgeLibraryIds: []
|
|
}
|
|
)
|
|
await run
|
|
await vi.waitFor(() => expect(statusConsumed).toBe(true))
|
|
|
|
const disposal = harness.dispose()
|
|
await expect(disposal).resolves.toBeUndefined()
|
|
expect(remoteSignal?.aborted).toBe(false)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
|
|
|
finishRun()
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
})
|
|
|
|
it('persists remote usage with the inherited project Runtime without pinning the conversation', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000790'
|
|
const conversationId = 'inherited-remote-usage'
|
|
const selection = { provider: 'opencode', profileId: '00000000-0000-4000-8000-000000000791' } as const
|
|
const provenance = {
|
|
source: 'remote-semantic-transcript',
|
|
bindingId: 'inherited-usage',
|
|
operationId: requestId,
|
|
semanticSequence: '1',
|
|
eventIndex: 0
|
|
} as const
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId, type: 'context-metrics',
|
|
contextTokens: 42, effectiveTriggerTokens: 1000,
|
|
compressionEnabled: false, source: 'provider',
|
|
remoteProvenance: provenance
|
|
} as const
|
|
yield {
|
|
requestId: request.requestId, type: 'done',
|
|
remoteProvenance: { ...provenance, semanticSequence: '2' }
|
|
} as const
|
|
}
|
|
}
|
|
const { harness, projectId } = createManagedSshHarness(selectedRuntime)
|
|
harness.getResolvedSettings.mockResolvedValue({
|
|
provider: 'opencode', defaultModelProfileId: selection.profileId, opencodeBaseUrl: '',
|
|
modelProfiles: [{
|
|
id: selection.profileId, name: 'Remote', modelName: 'remote', baseUrl: 'https://models.example/v1',
|
|
protocol: 'openai-chat-completions', authentication: 'none'
|
|
}]
|
|
})
|
|
const project = harness.assistantDatabase.getProject(projectId)
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
...project,
|
|
runtimeSelection: { provider: 'opencode', model: { kind: 'profile', profileId: selection.profileId } }
|
|
})
|
|
harness.assistantDatabase.getConversation.mockReturnValue({
|
|
id: conversationId, projectId, messages: []
|
|
})
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
...managedSshRequest(projectId, requestId, conversationId),
|
|
runtimeSelection: undefined,
|
|
currentUserMessageId: '00000000-0000-4000-8000-000000000792',
|
|
currentAssistantMessageId: '00000000-0000-4000-8000-000000000793'
|
|
})
|
|
await vi.waitFor(() => expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledWith(
|
|
expect.objectContaining({ event: expect.objectContaining({ type: 'done' }) })
|
|
))
|
|
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
conversationId,
|
|
runtimeSelection: selection,
|
|
event: expect.objectContaining({ type: 'context-metrics', contextTokens: 42 })
|
|
})
|
|
)
|
|
expect(harness.assistantDatabase.getConversation(conversationId)).not.toHaveProperty('runtimeSelection')
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('advertises workspace and delegation tools in the capability instruction', async () => {
|
|
const received: string[] = []
|
|
const runtime = {
|
|
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
|
|
async *run(request: { requestId: string; trustedInstructions?: string }) {
|
|
received.push(request.trustedInstructions ?? '')
|
|
yield { type: 'done', requestId: request.requestId } as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '00000000-0000-4000-8000-000000000794'
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId, conversationId: 'ask-workspace', prompt: 'Read the source'
|
|
})
|
|
await vi.waitFor(() => expect(received).toHaveLength(1))
|
|
for (const name of ['workspace_rg', 'workspace_read_text', 'output_read', 'subagent_delegate']) {
|
|
expect(received[0]).toContain(name)
|
|
}
|
|
expect(received[0]).not.toContain('workspace_apply_patch')
|
|
expect(received[0]).not.toContain('process_execute')
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('captures project conversations before deletion and waits for attachment reference release', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const project = database.createProject({ name: 'Delete refs', rootPath: process.cwd(), description: '' })
|
|
const conversationId = crypto.randomUUID()
|
|
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Attached', updatedAt: Date.now() }, messages: [] }])
|
|
const harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
|
|
false, undefined, undefined, undefined, undefined, database)
|
|
let release!: () => void
|
|
const gate = new Promise<void>(resolve => { release = resolve })
|
|
const deleteProject = vi.fn(async () => { await gate })
|
|
Object.assign(harness.contextManager, { assets: { deleteProject }, activeContextIds: () => ['active-draft'], cancelUnavailableImport: vi.fn(async () => {}) })
|
|
try {
|
|
let completed = false
|
|
const pending = Promise.resolve(electronMocks.handlers.get(ipcChannels.projectsDelete)!(trustedEvent(harness.webContents), {
|
|
projectId: project.id, confirmation: project.name
|
|
})).then(() => { completed = true })
|
|
await vi.waitFor(() => expect(deleteProject).toHaveBeenCalledWith([conversationId], ['active-draft']))
|
|
expect(database.listConversationSummaries().some(conversation => conversation.id === conversationId)).toBe(false)
|
|
expect(completed).toBe(false)
|
|
release()
|
|
await pending
|
|
expect(completed).toBe(true)
|
|
} finally {
|
|
release()
|
|
await harness.dispose()
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('durably commits remote text at its checkpoint before resume, deduplicates it, and preserves its semantic payload', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000763'
|
|
const provenance = {
|
|
source: 'remote-semantic-transcript' as const,
|
|
bindingId: 'binding-semantic-text',
|
|
operationId: 'operation-semantic-text',
|
|
semanticSequence: '1',
|
|
eventIndex: 0
|
|
}
|
|
const taggedDelta = '<think>remote reasoning</think>remote answer'
|
|
const resumed = vi.fn()
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
const remoteText = {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: taggedDelta,
|
|
remoteProvenance: provenance
|
|
} as const
|
|
const checkpoint = {
|
|
requestId: request.requestId,
|
|
type: 'remote-semantic-checkpoint',
|
|
remoteProvenance: { ...provenance, eventIndex: 1 }
|
|
} as const
|
|
yield remoteText
|
|
yield checkpoint
|
|
// The Agent may ACK the entry here: text and checkpoint must already
|
|
// be committed, together, in one batch.
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteTaskEventsOnce
|
|
).toHaveBeenCalledOnce()
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteTaskEventsOnce
|
|
).toHaveBeenLastCalledWith([
|
|
{
|
|
taskId: request.requestId,
|
|
bindingId: provenance.bindingId,
|
|
operationId: provenance.operationId,
|
|
semanticSequence: provenance.semanticSequence,
|
|
eventIndex: provenance.eventIndex,
|
|
kind: 'text',
|
|
payload: {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: taggedDelta
|
|
}
|
|
},
|
|
expect.objectContaining({
|
|
eventIndex: 1,
|
|
kind: 'remote-semantic-checkpoint'
|
|
})
|
|
])
|
|
resumed('first')
|
|
// Reconnect replay of the same transcript entry.
|
|
yield remoteText
|
|
yield checkpoint
|
|
resumed('duplicate')
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done',
|
|
remoteProvenance: {
|
|
...provenance,
|
|
semanticSequence: '2'
|
|
}
|
|
} as const
|
|
}
|
|
}
|
|
const fixture = createManagedSshHarness(selectedRuntime)
|
|
const { harness } = fixture
|
|
let releaseCommit!: () => void
|
|
const commitGate = new Promise<void>(resolve => { releaseCommit = resolve })
|
|
let commitStarted = false
|
|
beforeStorageCall.set(harness.assistantDatabase, async method => {
|
|
if (method === 'appendRemoteTaskEventsOnce' && !commitStarted) {
|
|
commitStarted = true
|
|
await commitGate
|
|
}
|
|
})
|
|
harness.assistantDatabase.appendRemoteTaskEventOnce
|
|
.mockReturnValueOnce(true)
|
|
.mockReturnValueOnce(true)
|
|
.mockReturnValueOnce(false)
|
|
.mockReturnValueOnce(false)
|
|
.mockReturnValueOnce(true)
|
|
|
|
const pendingRequest = harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
managedSshRequest(
|
|
fixture.projectId,
|
|
requestId,
|
|
'managed-ssh-semantic-text'
|
|
)
|
|
)
|
|
await vi.waitFor(() => expect(commitStarted).toBe(true))
|
|
expect(resumed).not.toHaveBeenCalled()
|
|
expect(harness.webContents.send.mock.calls.some(([channel, value]) =>
|
|
channel === ipcChannels.agentEvent && value.requestId === requestId && value.type === 'text')).toBe(false)
|
|
releaseCommit()
|
|
await pendingRequest
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness?.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
|
|
expect(resumed.mock.calls).toEqual([['first'], ['duplicate']])
|
|
// One transaction per checkpoint plus one for the terminal event.
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteTaskEventsOnce
|
|
).toHaveBeenCalledTimes(3)
|
|
const persistedTextCalls =
|
|
harness.assistantDatabase.appendRemoteTaskEventOnce.mock.calls.filter(
|
|
([input]) => input.kind === 'text'
|
|
)
|
|
expect(persistedTextCalls).toHaveLength(2)
|
|
const publicEvents = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, event]) => event)
|
|
.filter((event) => event.requestId === requestId)
|
|
expect(publicEvents).toEqual([
|
|
{
|
|
requestId,
|
|
type: 'text',
|
|
delta: taggedDelta
|
|
},
|
|
{
|
|
requestId,
|
|
type: 'done'
|
|
}
|
|
])
|
|
expect(
|
|
publicEvents.some((event) => event.type === 'reasoning')
|
|
).toBe(false)
|
|
expect(
|
|
publicEvents.some((event) => 'remoteProvenance' in event)
|
|
).toBe(false)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('batches a remote transcript page into one transaction and keeps order with local events', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000767'
|
|
const conversationId = 'managed-ssh-remote-batch'
|
|
const remote = (semanticSequence: string, eventIndex: number) => ({
|
|
source: 'remote-semantic-transcript' as const,
|
|
bindingId: 'binding-remote-batch',
|
|
operationId: requestId,
|
|
semanticSequence,
|
|
eventIndex
|
|
})
|
|
const lifecycle: string[] = []
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
const id = request.requestId
|
|
yield { requestId: id, type: 'reasoning', delta: 'think', remoteProvenance: remote('1', 0) } as const
|
|
yield { requestId: id, type: 'text', delta: 'A', remoteProvenance: remote('1', 1) } as const
|
|
yield { requestId: id, type: 'text', delta: 'B', remoteProvenance: remote('1', 2) } as const
|
|
// Replayed event from an earlier page: already stored.
|
|
yield { requestId: id, type: 'text', delta: 'old', remoteProvenance: remote('1', 3) } as const
|
|
lifecycle.push('before-checkpoint')
|
|
yield { requestId: id, type: 'remote-semantic-checkpoint', remoteProvenance: remote('1', 4) } as const
|
|
lifecycle.push('after-checkpoint')
|
|
yield { requestId: id, type: 'text', delta: 'C', remoteProvenance: remote('2', 0) } as const
|
|
// A local event forces the pending remote batch to commit first.
|
|
yield { requestId: id, type: 'status', message: 'local status' } as const
|
|
lifecycle.push('after-local')
|
|
yield { requestId: id, type: 'remote-semantic-checkpoint', remoteProvenance: remote('2', 1) } as const
|
|
yield { requestId: id, type: 'done', remoteProvenance: remote('3', 0) } as const
|
|
yield { requestId: id, type: 'remote-semantic-checkpoint', remoteProvenance: remote('3', 1) } as const
|
|
}
|
|
}
|
|
const { harness, projectId } = createManagedSshHarness(selectedRuntime)
|
|
harness.assistantDatabase.getConversation.mockReturnValue({
|
|
id: conversationId, projectId, messages: []
|
|
})
|
|
harness.assistantDatabase.appendRemoteConversationTaskEventsBatch.mockImplementation(
|
|
({ events }) => {
|
|
lifecycle.push(`batch:${events.map(({ event }) => event.type).join(',')}`)
|
|
return events.map(({ event }) => !(event.type === 'text' && event.delta === 'old'))
|
|
}
|
|
)
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
...managedSshRequest(projectId, requestId, conversationId),
|
|
currentUserMessageId: '00000000-0000-4000-8000-000000000768',
|
|
currentAssistantMessageId: '00000000-0000-4000-8000-000000000769'
|
|
})
|
|
await vi.waitFor(() => expect(lifecycle).toContain('batch:remote-semantic-checkpoint'))
|
|
|
|
expect(lifecycle).toEqual([
|
|
'before-checkpoint',
|
|
'batch:reasoning,text,text,text,remote-semantic-checkpoint',
|
|
'after-checkpoint',
|
|
'batch:text',
|
|
'after-local',
|
|
'batch:remote-semantic-checkpoint',
|
|
'batch:done',
|
|
'batch:remote-semantic-checkpoint'
|
|
])
|
|
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled()
|
|
expect(harness.assistantDatabase.appendRemoteTaskEventOnce).not.toHaveBeenCalled()
|
|
const publicEvents = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, event]) => event)
|
|
.filter((event) => event.requestId === requestId)
|
|
// Renderer coalescing may merge adjacent text deltas; compare the stream.
|
|
const stream: string[] = []
|
|
for (const event of publicEvents) {
|
|
const previous = stream.at(-1)
|
|
if ((event.type === 'text' || event.type === 'reasoning') && previous?.startsWith(`${event.type}:`)) {
|
|
stream[stream.length - 1] = `${previous}${event.delta}`
|
|
} else {
|
|
stream.push(event.type === 'text' || event.type === 'reasoning'
|
|
? `${event.type}:${event.delta}`
|
|
: event.type === 'status' ? `status:${event.message}` : event.type)
|
|
}
|
|
}
|
|
expect(stream).toEqual(['reasoning:think', 'text:ABC', 'status:local status', 'done'])
|
|
expect(publicEvents.some((event) => event.type === 'text' && event.delta.includes('old'))).toBe(false)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('fails the run without forwarding or acknowledging when a remote batch commit fails', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000770'
|
|
const remote = (eventIndex: number) => ({
|
|
source: 'remote-semantic-transcript' as const,
|
|
bindingId: 'binding-remote-batch-failure',
|
|
operationId: requestId,
|
|
semanticSequence: '1',
|
|
eventIndex
|
|
})
|
|
const resumedAfterCheckpoint = vi.fn()
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'text', delta: 'lost', remoteProvenance: remote(0) } as const
|
|
yield { requestId: request.requestId, type: 'remote-semantic-checkpoint', remoteProvenance: remote(1) } as const
|
|
resumedAfterCheckpoint()
|
|
}
|
|
}
|
|
const fixture = createManagedSshHarness(selectedRuntime)
|
|
const { harness } = fixture
|
|
harness.assistantDatabase.appendRemoteTaskEventsOnce.mockImplementation(() => {
|
|
throw new Error('disk full')
|
|
})
|
|
await harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
managedSshRequest(fixture.projectId, requestId, 'managed-ssh-batch-failure')
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
expect.stringMatching(/^(failed|interrupted)$/u),
|
|
expect.any(String)
|
|
)
|
|
)
|
|
expect(resumedAfterCheckpoint).not.toHaveBeenCalled()
|
|
const publicEvents = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, event]) => event)
|
|
.filter((event) => event.requestId === requestId)
|
|
expect(publicEvents.some((event) => event.type === 'text')).toBe(false)
|
|
expect(publicEvents.at(-1)).toMatchObject({ type: 'error' })
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('persists a remote semantic checkpoint without publishing it', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000764'
|
|
const provenance = {
|
|
source: 'remote-semantic-transcript' as const,
|
|
bindingId: 'binding-semantic-checkpoint',
|
|
operationId: 'operation-semantic-checkpoint',
|
|
semanticSequence: '1',
|
|
eventIndex: 0
|
|
}
|
|
const resumedAfterCheckpoint = vi.fn()
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'remote-semantic-checkpoint',
|
|
remoteProvenance: provenance
|
|
} as const
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteTaskEventOnce
|
|
).toHaveBeenCalledWith({
|
|
taskId: request.requestId,
|
|
bindingId: provenance.bindingId,
|
|
operationId: provenance.operationId,
|
|
semanticSequence: provenance.semanticSequence,
|
|
eventIndex: provenance.eventIndex,
|
|
kind: 'remote-semantic-checkpoint',
|
|
payload: {
|
|
requestId: request.requestId,
|
|
type: 'remote-semantic-checkpoint'
|
|
}
|
|
})
|
|
resumedAfterCheckpoint()
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done',
|
|
remoteProvenance: {
|
|
...provenance,
|
|
semanticSequence: '2'
|
|
}
|
|
} as const
|
|
}
|
|
}
|
|
const fixture = createManagedSshHarness(selectedRuntime)
|
|
const { harness } = fixture
|
|
|
|
await harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
managedSshRequest(
|
|
fixture.projectId,
|
|
requestId,
|
|
'managed-ssh-semantic-checkpoint'
|
|
)
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
|
|
expect(resumedAfterCheckpoint).toHaveBeenCalledOnce()
|
|
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({
|
|
requestId,
|
|
type: 'remote-semantic-checkpoint'
|
|
})
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('persists remote done before resuming the generator once for Agent acknowledgement', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000765'
|
|
const lifecycle: string[] = []
|
|
const provenance = {
|
|
source: 'remote-semantic-transcript' as const,
|
|
bindingId: 'binding-semantic-done',
|
|
operationId: 'operation-semantic-done',
|
|
semanticSequence: '1',
|
|
eventIndex: 0
|
|
}
|
|
const resumedAfterDone = vi.fn(() => {
|
|
lifecycle.push('resume-done')
|
|
})
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done',
|
|
remoteProvenance: provenance
|
|
} as const
|
|
resumedAfterDone()
|
|
}
|
|
}
|
|
const { harness, projectId } =
|
|
createManagedSshHarness(selectedRuntime)
|
|
harness.assistantDatabase.appendRemoteTaskEventOnce.mockImplementation(
|
|
(input) => {
|
|
lifecycle.push(`persist-${input.kind}`)
|
|
return true
|
|
}
|
|
)
|
|
|
|
await harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
managedSshRequest(
|
|
projectId,
|
|
requestId,
|
|
'managed-ssh-semantic-done'
|
|
)
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
|
|
expect(lifecycle).toEqual(['persist-done', 'resume-done'])
|
|
expect(resumedAfterDone).toHaveBeenCalledOnce()
|
|
expect(
|
|
harness.assistantDatabase.appendTaskEvent
|
|
).not.toHaveBeenCalledWith(
|
|
requestId,
|
|
'done',
|
|
expect.anything()
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('persists and acknowledges a remote terminal error before failing without duplicate delivery', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000766'
|
|
const errorMessage = 'remote Agent failed'
|
|
const lifecycle: string[] = []
|
|
const provenance = {
|
|
source: 'remote-semantic-transcript' as const,
|
|
bindingId: 'binding-semantic-error',
|
|
operationId: 'operation-semantic-error',
|
|
semanticSequence: '1',
|
|
eventIndex: 0
|
|
}
|
|
const resumedAfterError = vi.fn(() => {
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
expect.any(String)
|
|
)
|
|
lifecycle.push('resume-error')
|
|
})
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'error',
|
|
status: 'failed',
|
|
message: errorMessage,
|
|
remoteProvenance: provenance
|
|
} as const
|
|
resumedAfterError()
|
|
}
|
|
}
|
|
const fixture = createManagedSshHarness(selectedRuntime)
|
|
const { harness } = fixture
|
|
harness.assistantDatabase.appendRemoteTaskEventOnce.mockImplementation(
|
|
(input) => {
|
|
lifecycle.push(`persist-${input.kind}`)
|
|
return true
|
|
}
|
|
)
|
|
|
|
await harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
managedSshRequest(
|
|
fixture.projectId,
|
|
requestId,
|
|
'managed-ssh-semantic-error'
|
|
)
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
expect.any(String)
|
|
)
|
|
)
|
|
|
|
expect(lifecycle).toEqual(['persist-error', 'resume-error'])
|
|
expect(resumedAfterError).toHaveBeenCalledOnce()
|
|
expect(
|
|
harness.assistantDatabase.appendTaskEvent
|
|
).not.toHaveBeenCalledWith(
|
|
requestId,
|
|
'error',
|
|
expect.anything()
|
|
)
|
|
const publicErrors = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, event]) => event)
|
|
.filter(
|
|
(event) =>
|
|
event.requestId === requestId && event.type === 'error'
|
|
)
|
|
expect(publicErrors).toEqual([
|
|
{
|
|
requestId,
|
|
type: 'error',
|
|
status: 'failed',
|
|
message: errorMessage
|
|
}
|
|
])
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each([false, true])('reattaches from its committed cursor with tool recovery evidence %s', async (hasRecoveredResponse) => {
|
|
const projectId = '00000000-0000-4000-8000-000000000781'
|
|
const taskId = '00000000-0000-4000-8000-000000000782'
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000783'
|
|
const userMessageId =
|
|
'00000000-0000-4000-8000-000000000784'
|
|
const assistantMessageId =
|
|
'00000000-0000-4000-8000-000000000785'
|
|
const consumed: string[] = []
|
|
const recoveredRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: {
|
|
requestId: string
|
|
remoteRecoveryOnly?: boolean
|
|
remoteSemanticAfterSequence?: string
|
|
remoteRecoveredTools?: unknown[]
|
|
}) {
|
|
expect(request).toMatchObject({
|
|
requestId: taskId,
|
|
remoteRecoveryOnly: true,
|
|
remoteSemanticAfterSequence: '2',
|
|
remoteHasResponseTextAfterToolFailure: hasRecoveredResponse,
|
|
remoteRecoveredTools: [
|
|
expect.objectContaining({
|
|
callId: 'tool-before-restart',
|
|
name: 'Read',
|
|
state: 'failed'
|
|
})
|
|
]
|
|
})
|
|
yield {
|
|
requestId: taskId,
|
|
type: 'text',
|
|
delta: 'recovered answer',
|
|
remoteProvenance: {
|
|
source: 'remote-semantic-transcript',
|
|
bindingId: 'binding-recovery',
|
|
operationId: taskId,
|
|
semanticSequence: '3',
|
|
eventIndex: 0
|
|
}
|
|
} as const
|
|
consumed.push('3')
|
|
yield {
|
|
requestId: taskId,
|
|
type: 'remote-semantic-checkpoint',
|
|
remoteProvenance: {
|
|
source: 'remote-semantic-transcript',
|
|
bindingId: 'binding-recovery',
|
|
operationId: taskId,
|
|
semanticSequence: '3',
|
|
eventIndex: 1
|
|
}
|
|
} as const
|
|
if (hasRecoveredResponse) yield {
|
|
requestId: taskId, type: 'tool', callId: 'tool-before-restart',
|
|
name: 'Read', state: 'recoverable', summary: 'Handled missing file', error: 'missing file',
|
|
remoteProvenance: {
|
|
source: 'remote-semantic-transcript', bindingId: 'binding-recovery',
|
|
operationId: taskId, semanticSequence: '4', eventIndex: 0
|
|
}
|
|
} as const
|
|
yield {
|
|
requestId: taskId,
|
|
type: 'done',
|
|
sessionId: 'session-recovery',
|
|
remoteProvenance: {
|
|
source: 'remote-semantic-transcript',
|
|
bindingId: 'binding-recovery',
|
|
operationId: taskId,
|
|
semanticSequence: '4',
|
|
eventIndex: hasRecoveredResponse ? 1 : 0
|
|
}
|
|
} as const
|
|
consumed.push('4')
|
|
yield {
|
|
requestId: taskId,
|
|
type: 'remote-semantic-checkpoint',
|
|
remoteProvenance: {
|
|
source: 'remote-semantic-transcript',
|
|
bindingId: 'binding-recovery',
|
|
operationId: taskId,
|
|
semanticSequence: '4',
|
|
eventIndex: hasRecoveredResponse ? 2 : 1
|
|
}
|
|
} as const
|
|
}
|
|
}
|
|
const {
|
|
harness,
|
|
selectedRuntimes
|
|
} = createManagedSshHarness(recoveredRuntime)
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
id: projectId,
|
|
runtimeSelection: { provider: 'opencode' },
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000786',
|
|
remoteRootPath: '/srv/project'
|
|
}
|
|
})
|
|
harness.assistantDatabase.getConversation.mockReturnValue({
|
|
id: conversationId,
|
|
projectId,
|
|
runtimeSelection: { provider: 'opencode' },
|
|
messages: [
|
|
{
|
|
id: assistantMessageId,
|
|
role: 'assistant',
|
|
content: '',
|
|
state: 'streaming',
|
|
createdAt: 1,
|
|
tools: [
|
|
{
|
|
callId: 'tool-before-restart',
|
|
name: 'Read',
|
|
state: 'failed',
|
|
summary: 'Read failed',
|
|
error: 'missing file'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
})
|
|
harness.assistantDatabase.listRecoverableRemoteTasks.mockReturnValue([
|
|
{
|
|
taskId,
|
|
projectId,
|
|
conversationId,
|
|
currentUserMessageId: userMessageId,
|
|
currentAssistantMessageId: assistantMessageId,
|
|
instructions: 'continue remotely',
|
|
status: 'running'
|
|
}
|
|
])
|
|
harness.assistantDatabase.getHighestCommittedRemoteTaskEventSequenceForTask.mockReturnValue(
|
|
'2'
|
|
)
|
|
harness.assistantDatabase.hasRemoteResponseTextAfterToolFailure.mockReturnValue(hasRecoveredResponse)
|
|
|
|
harness.recoveryGetHandler?.(
|
|
trustedEvent(harness.webContents)
|
|
)
|
|
|
|
await vi.waitFor(() => expect(consumed).toEqual(['3', '4']))
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteConversationTaskEventOnce
|
|
).toHaveBeenCalledTimes(hasRecoveredResponse ? 5 : 4)
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteConversationTaskEventOnce
|
|
).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
taskId,
|
|
conversationId,
|
|
assistantMessageId,
|
|
semanticSequence: '3',
|
|
event: {
|
|
requestId: taskId,
|
|
type: 'text',
|
|
delta: 'recovered answer'
|
|
}
|
|
})
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteConversationTaskEventOnce
|
|
).toHaveBeenNthCalledWith(
|
|
hasRecoveredResponse ? 4 : 3,
|
|
expect.objectContaining({
|
|
taskId,
|
|
semanticSequence: '4',
|
|
event: hasRecoveredResponse ? {
|
|
requestId: taskId, type: 'done', sessionId: 'session-recovery'
|
|
} : {
|
|
requestId: taskId,
|
|
type: 'error',
|
|
status: 'failed',
|
|
message: 'Read 工具执行失败:missing file'
|
|
}
|
|
})
|
|
)
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.remoteProjectRecoveryProgress,
|
|
expect.objectContaining({
|
|
projectId,
|
|
stage: 'completed'
|
|
})
|
|
)
|
|
)
|
|
// The renderer converges on the persisted terminal state by re-reading
|
|
// conversations, so the change notice must land before `completed`.
|
|
const sendCalls = harness.webContents.send.mock.calls
|
|
const completedIndex = sendCalls.findIndex(
|
|
([channel, payload]) =>
|
|
channel === ipcChannels.remoteProjectRecoveryProgress &&
|
|
(payload as { stage?: string }).stage === 'completed'
|
|
)
|
|
expect(completedIndex).toBeGreaterThan(0)
|
|
expect(
|
|
sendCalls
|
|
.slice(0, completedIndex)
|
|
.some(([channel]) => channel === ipcChannels.conversationsChanged)
|
|
).toBe(true)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('replays an error committed before its checkpoint using remote tool states instead of terminal message states', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize('C:\\Workspace')
|
|
const project = database.createSshProject({
|
|
project: { name: 'Partial terminal', description: '', rootPath: '/srv/project', runtimeSelection: { provider: 'opencode' } },
|
|
executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000850', remoteRootPath: '/srv/project' },
|
|
assertCurrent: () => {}
|
|
})
|
|
const taskId = '00000000-0000-4000-8000-000000000851'
|
|
const conversationId = '00000000-0000-4000-8000-000000000852'
|
|
const assistantMessageId = '00000000-0000-4000-8000-000000000853'
|
|
database.saveLocalConversations([{
|
|
header: { id: conversationId, projectId: project.id, title: 'Partial terminal', updatedAt: 0 }, messages: []
|
|
}])
|
|
database.createTask({
|
|
id: taskId, projectId: project.id, conversationId, title: 'Partial terminal', instructions: 'Read', remoteRecovery: { recoverable: true, currentUserMessageId: '00000000-0000-4000-8000-000000000854', currentAssistantMessageId: assistantMessageId }
|
|
})
|
|
const append = (semanticSequence: string, eventIndex: number, event: Parameters<AssistantDatabase['appendRemoteConversationTaskEventOnce']>[0]['event']) =>
|
|
database.appendRemoteConversationTaskEventOnce({
|
|
taskId, conversationId, assistantMessageId, bindingId: 'partial-terminal', operationId: taskId,
|
|
semanticSequence, eventIndex, event
|
|
})
|
|
const events: AgentEvent[] = [
|
|
{ requestId: taskId, type: 'tool', callId: 'A', name: 'Read', state: 'failed', summary: 'Read', error: 'missing file' },
|
|
{ requestId: taskId, type: 'text', delta: 'Handled missing file' },
|
|
{ requestId: taskId, type: 'tool', callId: 'B', name: 'Write', state: 'running', summary: 'Write', input: 'preserve input' }
|
|
]
|
|
events.forEach((event, index) => {
|
|
append(String(index + 1), 0, event)
|
|
append(String(index + 1), 1, { requestId: taskId, type: 'remote-semantic-checkpoint' })
|
|
})
|
|
const error: AgentEvent = { requestId: taskId, type: 'error', status: 'failed', message: 'Read 工具执行失败:missing file' }
|
|
append('4', 0, error)
|
|
expect(database.getConversation(conversationId).messages[1]?.tools?.map(tool => tool.state)).toEqual(['failed', 'failed'])
|
|
expect(database.getTask(taskId).status).toBe('running')
|
|
const recoveredRuntime = {
|
|
runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true,
|
|
async *run(request: AgentExecutionRequest) {
|
|
expect(request).toMatchObject({
|
|
requestId: taskId, remoteRecoveryOnly: true, remoteSemanticAfterSequence: '3',
|
|
remoteHasResponseTextAfterToolFailure: true,
|
|
remoteRecoveredTools: [
|
|
expect.objectContaining({ callId: 'A', state: 'failed', error: 'missing file' }),
|
|
expect.objectContaining({ callId: 'B', state: 'running', input: 'preserve input' })
|
|
]
|
|
})
|
|
const remoteProvenance = {
|
|
source: 'remote-semantic-transcript' as const, bindingId: 'partial-terminal',
|
|
operationId: taskId, semanticSequence: '4', eventIndex: 0
|
|
}
|
|
yield { requestId: taskId, type: 'done' as const, remoteProvenance }
|
|
yield { requestId: taskId, type: 'remote-semantic-checkpoint' as const, remoteProvenance: { ...remoteProvenance, eventIndex: 1 } }
|
|
}
|
|
}
|
|
const { harness } = createManagedSshHarness(recoveredRuntime)
|
|
harness.assistantDatabase.getProject.mockImplementation(database.getProject.bind(database))
|
|
harness.assistantDatabase.getConversation.mockImplementation(database.getConversation.bind(database))
|
|
harness.assistantDatabase.listRecoverableRemoteTasks.mockImplementation(database.listRecoverableRemoteTasks.bind(database))
|
|
harness.assistantDatabase.getHighestCommittedRemoteTaskEventSequenceForTask.mockImplementation(database.getHighestCommittedRemoteTaskEventSequenceForTask.bind(database))
|
|
harness.assistantDatabase.hasRemoteResponseTextAfterToolFailure.mockImplementation(database.hasRemoteResponseTextAfterToolFailure.bind(database))
|
|
harness.assistantDatabase.getRemoteTaskActivityStates.mockImplementation(database.getRemoteTaskActivityStates.bind(database))
|
|
harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database))
|
|
try {
|
|
harness.recoveryGetHandler?.(trustedEvent(harness.webContents))
|
|
await vi.waitFor(() => expect(database.getTask(taskId).status).toBe('failed'))
|
|
expect(database.getHighestCommittedRemoteTaskEventSequenceForTask(taskId)).toBe('4')
|
|
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenNthCalledWith(1,
|
|
expect.objectContaining({ semanticSequence: '4', eventIndex: 0, event: error }))
|
|
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mock.results.map(result => result.value)).toEqual([false, true])
|
|
expect(database.getRemoteTaskActivityStates(taskId).tools.get('B')).toBe('running')
|
|
} finally {
|
|
await harness.dispose()
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it.each(['runtime', 'customization', 'capabilities'] as const)('restores concurrent live questions and their answer history across %s settings reload', async (settingsKind) => {
|
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-recovered-question-history-'))
|
|
const databasePath = join(directory, 'assistant.sqlite')
|
|
const database = new AssistantDatabase(databasePath)
|
|
database.initialize(directory)
|
|
const project = database.createSshProject({
|
|
project: {
|
|
name: 'Recovered questions', description: '', rootPath: '/srv/project',
|
|
runtimeSelection: { provider: 'opencode' }
|
|
},
|
|
executionSpace: {
|
|
kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000850',
|
|
remoteRootPath: '/srv/project'
|
|
},
|
|
assertCurrent: () => {}
|
|
})
|
|
const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
|
const tasks = [1, 2].map(n => ({
|
|
taskId: id(810 + n), conversationId: id(820 + n), projectId: project.id,
|
|
currentUserMessageId: id(830 + n), currentAssistantMessageId: id(840 + n),
|
|
instructions: 'Original question', status: 'running' as const
|
|
}))
|
|
for (const task of tasks) {
|
|
database.saveLocalConversations([{
|
|
header: {
|
|
id: task.conversationId, projectId: project.id,
|
|
title: 'Recovered', updatedAt: Date.now(), runtimeSelection: { provider: 'opencode' }
|
|
},
|
|
messages: []
|
|
}])
|
|
database.createTask({
|
|
id: task.taskId, conversationId: task.conversationId, projectId: project.id,
|
|
title: 'Recovered question', instructions: task.instructions, remoteRecovery: {
|
|
recoverable: true, currentUserMessageId: task.currentUserMessageId,
|
|
currentAssistantMessageId: task.currentAssistantMessageId
|
|
}
|
|
})
|
|
}
|
|
const ready = new Set<string>()
|
|
const signals: AbortSignal[] = []
|
|
const finish = new Map<string, () => void>()
|
|
const runtime = {
|
|
runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true,
|
|
respondToQuestion: vi.fn(async (questionId: string) => { finish.get(questionId)?.() }),
|
|
async *run(request: { requestId: string; remoteRecoveryOnly?: boolean }, signal: AbortSignal) {
|
|
signals.push(signal)
|
|
expect(request.remoteRecoveryOnly).toBe(true)
|
|
const answered = new Promise<void>(resolve => { finish.set(request.requestId, resolve) })
|
|
ready.add(request.requestId)
|
|
yield {
|
|
requestId: request.requestId, type: 'question' as const, questionId: request.requestId,
|
|
questions: [{ header: 'Input', question: 'Continue?', options: [], custom: true, multiple: false }]
|
|
}
|
|
await answered
|
|
yield {
|
|
requestId: request.requestId, type: 'done' as const, sessionId: request.requestId,
|
|
remoteProvenance: {
|
|
source: 'remote-semantic-transcript' as const, bindingId: request.requestId,
|
|
operationId: request.requestId, semanticSequence: '1', eventIndex: 0
|
|
}
|
|
}
|
|
yield {
|
|
requestId: request.requestId, type: 'remote-semantic-checkpoint' as const,
|
|
remoteProvenance: {
|
|
source: 'remote-semantic-transcript' as const, bindingId: request.requestId,
|
|
operationId: request.requestId, semanticSequence: '1', eventIndex: 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const { harness } = createManagedSshHarness(runtime)
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
id: project.id, runtimeSelection: { provider: 'opencode' }, rootPath: '/srv/project',
|
|
executionSpace: { kind: 'ssh', hostId: id(850), remoteRootPath: '/srv/project' }
|
|
})
|
|
harness.assistantDatabase.getConversation.mockImplementation(database.getConversation.bind(database))
|
|
harness.assistantDatabase.listConversations.mockImplementation(database.listConversations.bind(database))
|
|
harness.assistantDatabase.listConversationSummaries.mockImplementation(database.listConversationSummaries.bind(database))
|
|
harness.assistantDatabase.listRecoverableRemoteTasks.mockImplementation(database.listRecoverableRemoteTasks.bind(database))
|
|
harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database))
|
|
harness.assistantDatabase.updateTaskStatus.mockImplementation(database.updateTaskStatus.bind(database))
|
|
harness.assistantDatabase.getTask.mockImplementation(database.getTask.bind(database))
|
|
harness.assistantDatabase.recordRemoteTaskQuestionArrival.mockImplementation(database.recordRemoteTaskQuestionArrival.bind(database))
|
|
harness.assistantDatabase.recordRemoteTaskQuestionAnswer.mockImplementation(
|
|
(...args) => database.recordRemoteTaskQuestionAnswer(...args)
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
const list = async () => await electronMocks.handlers.get(ipcChannels.conversationsList)!(event) as ConversationSnapshot[]
|
|
try {
|
|
harness.recoveryGetHandler?.(event)
|
|
await vi.waitFor(() => expect(ready.size).toBe(2))
|
|
await vi.waitFor(async () => expect((await list()).every(c => c.activeRequest?.questions.length === 1)).toBe(true))
|
|
const summaries = await electronMocks.handlers.get(ipcChannels.conversationsListSummaries)!(event, { detailIds: [] }) as ConversationSnapshot[]
|
|
expect(summaries).toEqual(await list())
|
|
for (const snapshot of summaries) {
|
|
expect(await electronMocks.handlers.get(ipcChannels.conversationsGet)!(event, snapshot.id)).toEqual(snapshot)
|
|
}
|
|
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled()
|
|
const update = settingsKind === 'runtime'
|
|
? [ipcChannels.runtimeSettingsUpdate, runtimeSettingsInputSchema.strip().parse({
|
|
...defaultRuntimeSettings, workspacePath: directory, apiKey: { action: 'keep' }
|
|
})] as const
|
|
: settingsKind === 'customization'
|
|
? [ipcChannels.runtimeCustomizationUpdate, { opencode: { defaultAgent: 'build' }, continue: { presets: [] } }] as const
|
|
: [ipcChannels.capabilitiesToggleWebSearch, false] as const
|
|
await electronMocks.handlers.get(update[0])!(event, update[1])
|
|
expect(signals.every(signal => !signal.aborted)).toBe(true)
|
|
for (const [index, task] of [...tasks].reverse().entries()) {
|
|
const snapshot = (await list()).find(c => c.id === task.conversationId)!
|
|
expect(snapshot.activeRequest).toMatchObject({
|
|
requestId: task.taskId, messageId: task.currentAssistantMessageId
|
|
})
|
|
await electronMocks.handlers.get(ipcChannels.agentQuestionRespond)!(event, {
|
|
questionId: task.taskId, answers: index === 0 ? [['Yes']] : []
|
|
})
|
|
}
|
|
await vi.waitFor(async () => expect((await list()).every(c => !c.activeRequest)).toBe(true))
|
|
expect(runtime.respondToQuestion.mock.calls.map(([questionId]) => questionId))
|
|
.toEqual([...tasks].reverse().map(task => task.taskId))
|
|
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledTimes(4)
|
|
database.close()
|
|
const reopened = new AssistantDatabase(databasePath)
|
|
reopened.initialize(directory)
|
|
try {
|
|
for (const [index, task] of tasks.entries()) {
|
|
expect(reopened.getTask(task.taskId).status).toBe('completed')
|
|
expect(reopened.getConversation(task.conversationId).messages[1]).toMatchObject({
|
|
id: task.currentAssistantMessageId, state: 'complete',
|
|
blocks: [expect.objectContaining({ type: 'question', questionId: task.taskId })],
|
|
answeredQuestions: [{
|
|
questionId: task.taskId, skipped: index === 0,
|
|
questions: [{
|
|
header: 'Input', question: 'Continue?', options: [], multiple: false, custom: true,
|
|
...(index === 1 ? { answer: ['Yes'] } : {})
|
|
}]
|
|
}]
|
|
})
|
|
}
|
|
} finally {
|
|
reopened.close()
|
|
}
|
|
} finally {
|
|
for (const resolve of finish.values()) resolve()
|
|
await harness.dispose()
|
|
database.close()
|
|
await rm(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('aborts and drains a local run during Desktop shutdown', async () => {
|
|
let localSignal: AbortSignal | undefined
|
|
let finishLocalCleanup = (): void => undefined
|
|
let markLocalCleanupStarted = (): void => undefined
|
|
const localCleanupStarted = new Promise<void>((resolve) => {
|
|
markLocalCleanupStarted = resolve
|
|
})
|
|
const localRuntime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(
|
|
request: { requestId: string },
|
|
signal: AbortSignal
|
|
) {
|
|
localSignal = signal
|
|
await new Promise<void>((resolve) => {
|
|
signal.addEventListener('abort', () => resolve(), {
|
|
once: true
|
|
})
|
|
})
|
|
await new Promise<void>((resolve) => {
|
|
finishLocalCleanup = resolve
|
|
markLocalCleanupStarted()
|
|
})
|
|
signal.throwIfAborted()
|
|
yield { requestId: request.requestId, type: 'done' } as const
|
|
}
|
|
}
|
|
const harness = createHarness(localRuntime)
|
|
const requestId = '00000000-0000-4000-8000-000000000754'
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'local-shutdown-conversation',
|
|
prompt: 'local work',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() => expect(localSignal).toBeDefined())
|
|
|
|
const event = trustedEvent(harness.webContents)
|
|
electronMocks.handlers.get(
|
|
ipcChannels.appRendererPersistenceReady
|
|
)?.(event)
|
|
const disposal = harness.dispose()
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.appRendererPersistenceRequest,
|
|
expect.any(String)
|
|
)
|
|
)
|
|
|
|
expect(localSignal?.aborted).toBe(true)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
|
const persistenceRequestId =
|
|
harness.webContents.send.mock.calls.find(
|
|
([channel]) =>
|
|
channel === ipcChannels.appRendererPersistenceRequest
|
|
)?.[1]
|
|
electronMocks.handlers.get(
|
|
ipcChannels.appRendererPersistenceComplete
|
|
)?.(event, persistenceRequestId)
|
|
await localCleanupStarted
|
|
finishLocalCleanup()
|
|
await disposal
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消')
|
|
})
|
|
|
|
it.each([false, true])('recovers user Stop only while its remote result is uncertain (confirmed=%s)', async (confirmed) => {
|
|
let remoteSignal: AbortSignal | undefined
|
|
let statusConsumed = false
|
|
const remoteRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(
|
|
request: { requestId: string },
|
|
signal: AbortSignal
|
|
) {
|
|
remoteSignal = signal
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'status',
|
|
message: '远端 ACP Runtime 正在处理请求'
|
|
} as const
|
|
statusConsumed = true
|
|
await new Promise<void>((resolve) => {
|
|
signal.addEventListener('abort', () => resolve(), {
|
|
once: true
|
|
})
|
|
})
|
|
if (confirmed) {
|
|
throw new RemotePromptCancelledError('请求已取消')
|
|
}
|
|
signal.throwIfAborted()
|
|
yield { requestId: request.requestId, type: 'done' } as const
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => remoteRuntime)
|
|
}
|
|
const harness = createHarness(
|
|
remoteRuntime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
const projectId = '00000000-0000-4000-8000-000000000755'
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
id: projectId,
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000756',
|
|
remoteRootPath: '/srv/project'
|
|
}
|
|
})
|
|
const requestId = '00000000-0000-4000-8000-000000000757'
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'managed-ssh-stop-conversation',
|
|
projectId,
|
|
runtimeSelection: { provider: 'opencode' },
|
|
prompt: 'stop this',
|
|
knowledgeLibraryIds: [],
|
|
currentUserMessageId:
|
|
'00000000-0000-4000-8000-000000000758',
|
|
currentAssistantMessageId:
|
|
'00000000-0000-4000-8000-000000000759'
|
|
})
|
|
await vi.waitFor(() => expect(statusConsumed).toBe(true))
|
|
|
|
harness.cancelHandler?.(
|
|
trustedEvent(harness.webContents),
|
|
requestId
|
|
)
|
|
|
|
expect(remoteSignal?.aborted).toBe(true)
|
|
if (confirmed) {
|
|
await vi.waitFor(() => expect(
|
|
harness.assistantDatabase.endRecoverableRemoteTask
|
|
).toHaveBeenCalledWith(requestId, '请求已取消', 'cancelled'))
|
|
expect(harness.assistantDatabase.appendTaskEvent).not.toHaveBeenCalledWith(
|
|
requestId, 'error', expect.anything()
|
|
)
|
|
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
|
ipcChannels.remoteProjectRecoveryProgress,
|
|
expect.anything()
|
|
)
|
|
} else {
|
|
await vi.waitFor(() => expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'interrupted', '请求已取消'))
|
|
}
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('keeps a managed SSH start ambiguity recoverable before its first event', async () => {
|
|
const remoteRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(): AsyncGenerator<never, void, void> {
|
|
await Promise.reject(
|
|
new Error('prompt start response was lost')
|
|
)
|
|
yield undefined as never
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => remoteRuntime)
|
|
}
|
|
const harness = createHarness(
|
|
remoteRuntime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
const projectId = '00000000-0000-4000-8000-000000000760'
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
id: projectId,
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000761',
|
|
remoteRootPath: '/srv/project'
|
|
}
|
|
})
|
|
const requestId = '00000000-0000-4000-8000-000000000762'
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'managed-ssh-start-ambiguity',
|
|
projectId,
|
|
runtimeSelection: { provider: 'opencode' },
|
|
prompt: 'start once',
|
|
knowledgeLibraryIds: [],
|
|
currentUserMessageId:
|
|
'00000000-0000-4000-8000-000000000763',
|
|
currentAssistantMessageId:
|
|
'00000000-0000-4000-8000-000000000764'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(
|
|
requestId,
|
|
'interrupted',
|
|
'prompt start response was lost'
|
|
)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each(['list', 'claim', 'restore'] as const)('reserves a conversation during delayed queue %s and admits its dispatched request', async phase => {
|
|
const run = vi.fn(async function* (request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' } as const
|
|
})
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false, run })
|
|
const event = trustedEvent(harness.webContents)
|
|
const conversationId = crypto.randomUUID(), itemId = crypto.randomUUID()
|
|
const item = { id: itemId, conversationId, source: 'user' as const, label: 'queued', createdAt: new Date().toISOString() }
|
|
const input = { conversationId, prompt: 'queued', attachments: [], knowledgeLibraryIds: [] }
|
|
harness.assistantDatabase.listConversationQueueItems.mockReturnValueOnce([item])
|
|
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(item)
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({ source: 'user', item, payloadJson: JSON.stringify({ input, serializedContexts: '[]' }) })
|
|
let release!: () => void, entered = false
|
|
const gate = new Promise<void>(resolve => { release = resolve })
|
|
const wait = async () => { entered = true; await gate }
|
|
if (phase === 'restore') harness.contextManager.restoreFromQueue.mockImplementation(wait)
|
|
else beforeStorageCall.set(harness.assistantDatabase, async method => {
|
|
if (method === (phase === 'list' ? 'listConversationQueueItems' : 'claimConversationQueueItem')) await wait()
|
|
})
|
|
try {
|
|
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
|
|
await vi.waitFor(() => expect(entered).toBe(true))
|
|
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'overlap', knowledgeLibraryIds: [] })).rejects.toThrow('当前对话已有执行中的请求')
|
|
expect(run).not.toHaveBeenCalled()
|
|
release()
|
|
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.conversationQueueDispatch, expect.objectContaining({ item })))
|
|
await harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, queueItemId: itemId, prompt: 'queued', knowledgeLibraryIds: [] })
|
|
await vi.waitFor(() => expect(run).toHaveBeenCalledOnce())
|
|
} finally { release(); await harness.dispose() }
|
|
})
|
|
|
|
it.each(['lookup', 'claim', 'restore', 'send', 'release'] as const)('releases queue admission after a delayed %s failure', async phase => {
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const }
|
|
})
|
|
const event = trustedEvent(harness.webContents), conversationId = crypto.randomUUID(), itemId = crypto.randomUUID()
|
|
const item = { id: itemId, conversationId, source: 'user' as const, label: 'queued', createdAt: new Date().toISOString() }
|
|
harness.assistantDatabase.listConversationQueueItems.mockReturnValueOnce([item])
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({ source: 'user', item,
|
|
payloadJson: JSON.stringify({ input: { conversationId, prompt: 'queued', attachments: [] }, serializedContexts: '[]' }) })
|
|
const failure = new Error(`${phase} failed`)
|
|
const reported = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
if (phase === 'restore' || phase === 'release') harness.contextManager.restoreFromQueue.mockImplementation(async () => { await nextTurn(); throw failure })
|
|
else if (phase === 'send') harness.webContents.send.mockImplementation(channel => { if (channel === ipcChannels.conversationQueueDispatch) throw failure })
|
|
else beforeStorageCall.set(harness.assistantDatabase, async method => {
|
|
if (method === (phase === 'lookup' ? 'listConversationQueueItems' : 'claimConversationQueueItem')) { await nextTurn(); throw failure }
|
|
})
|
|
if (phase === 'release') beforeStorageCall.set(harness.assistantDatabase, async method => {
|
|
if (method === 'releaseConversationUserQueueItem') { await nextTurn(); throw failure }
|
|
})
|
|
try {
|
|
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
|
|
await vi.waitFor(() => {
|
|
if (phase === 'restore' || phase === 'send') expect(harness.assistantDatabase.releaseConversationUserQueueItem).toHaveBeenCalledWith(itemId)
|
|
else expect(reported).toHaveBeenCalled()
|
|
})
|
|
beforeStorageCall.delete(harness.assistantDatabase)
|
|
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'retry', knowledgeLibraryIds: [] })).resolves.toBeUndefined()
|
|
} finally { await harness.dispose(); reported.mockRestore() }
|
|
})
|
|
|
|
it('releases a delayed queue claim instead of dispatching after shutdown starts', async () => {
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false })
|
|
const conversationId = crypto.randomUUID(), itemId = crypto.randomUUID()
|
|
const item = { id: itemId, conversationId, source: 'user' as const, label: 'queued', createdAt: new Date().toISOString() }
|
|
harness.assistantDatabase.listConversationQueueItems.mockReturnValueOnce([item])
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({ source: 'user', item, payloadJson: '{}' })
|
|
let release!: () => void, entered = false
|
|
const gate = new Promise<void>(resolve => { release = resolve })
|
|
beforeStorageCall.set(harness.assistantDatabase, async method => { if (method === 'claimConversationQueueItem') { entered = true; await gate } })
|
|
let disposal: Promise<void> | undefined
|
|
try {
|
|
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(trustedEvent(harness.webContents), conversationId)
|
|
await vi.waitFor(() => expect(entered).toBe(true))
|
|
disposal = harness.dispose()
|
|
await nextTurn()
|
|
release()
|
|
await disposal
|
|
expect(harness.assistantDatabase.releaseConversationUserQueueItem).toHaveBeenCalledWith(itemId)
|
|
expect(harness.webContents.send).not.toHaveBeenCalledWith(ipcChannels.conversationQueueDispatch, expect.anything())
|
|
} finally { release(); await (disposal ?? harness.dispose()) }
|
|
})
|
|
|
|
it('reserves a dispatched request ID during queue validation and releases failed validation', async () => {
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const }
|
|
})
|
|
const event = trustedEvent(harness.webContents), conversationId = crypto.randomUUID(), itemId = crypto.randomUUID(), requestId = crypto.randomUUID()
|
|
const item = { id: itemId, conversationId, source: 'user' as const, label: 'queued', createdAt: new Date().toISOString() }
|
|
harness.assistantDatabase.listConversationQueueItems.mockReturnValueOnce([item])
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({ source: 'user', item, payloadJson: JSON.stringify({ conversationId, prompt: 'queued', attachments: [] }) })
|
|
let release!: () => void, entered = false
|
|
const gate = new Promise<void>(resolve => { release = resolve })
|
|
let finishRelease!: () => void, releasing = false
|
|
const releaseGate = new Promise<void>(resolve => { finishRelease = resolve })
|
|
try {
|
|
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
|
|
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.conversationQueueDispatch, expect.objectContaining({ item })))
|
|
beforeStorageCall.set(harness.assistantDatabase, async method => {
|
|
if (method === 'getConversationQueueItem') { entered = true; await gate; throw new Error('validation read failed') }
|
|
if (method === 'releaseConversationUserQueueItem') { releasing = true; await releaseGate }
|
|
})
|
|
const pending = expect(harness.handler!(event, { requestId, conversationId, queueItemId: itemId, prompt: 'queued', knowledgeLibraryIds: [] })).rejects.toThrow('validation read failed')
|
|
await vi.waitFor(() => expect(entered).toBe(true))
|
|
await expect(harness.handler!(event, { requestId, conversationId: crypto.randomUUID(), prompt: 'duplicate ID', knowledgeLibraryIds: [] })).rejects.toThrow('请求正在执行')
|
|
release()
|
|
await vi.waitFor(() => expect(releasing).toBe(true))
|
|
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'during cleanup', knowledgeLibraryIds: [] })).rejects.toThrow('当前对话已有执行中的请求')
|
|
finishRelease()
|
|
await pending
|
|
expect(harness.assistantDatabase.releaseConversationUserQueueItem).toHaveBeenCalledWith(itemId)
|
|
beforeStorageCall.delete(harness.assistantDatabase)
|
|
await expect(harness.handler!(event, { requestId, conversationId, prompt: 'retry', knowledgeLibraryIds: [] })).resolves.toBeUndefined()
|
|
} finally { release?.(); finishRelease(); await harness.dispose() }
|
|
})
|
|
|
|
it.each([false, true])('holds admission until renderer queue release settles (failure: %s)', async fail => {
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const }
|
|
})
|
|
const event = trustedEvent(harness.webContents), conversationId = crypto.randomUUID(), itemId = crypto.randomUUID()
|
|
harness.assistantDatabase.getConversationQueueItem.mockReturnValue({ id: itemId, conversationId, source: 'user', label: 'queued', createdAt: new Date().toISOString() })
|
|
let release!: () => void, entered = false
|
|
const gate = new Promise<void>(resolve => { release = resolve })
|
|
beforeStorageCall.set(harness.assistantDatabase, async method => {
|
|
if (method === 'releaseConversationUserQueueItem') { entered = true; await gate; if (fail) throw new Error('release failed') }
|
|
})
|
|
const operation = electronMocks.handlers.get(ipcChannels.conversationQueueReleaseUser)!(event, itemId)
|
|
const pending = fail ? expect(operation).rejects.toThrow('release failed') : expect(operation).resolves.toBeUndefined()
|
|
try {
|
|
await vi.waitFor(() => expect(entered).toBe(true))
|
|
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'overlap', knowledgeLibraryIds: [] })).rejects.toThrow('当前对话已有执行中的请求')
|
|
release()
|
|
await pending
|
|
beforeStorageCall.delete(harness.assistantDatabase)
|
|
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'retry', knowledgeLibraryIds: [] })).resolves.toBeUndefined()
|
|
} finally { release(); await pending; await harness.dispose() }
|
|
})
|
|
|
|
it('reserves manual compaction before its first storage read and releases failed preparation', async () => {
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const }
|
|
})
|
|
const event = trustedEvent(harness.webContents), conversationId = crypto.randomUUID(), requestId = crypto.randomUUID()
|
|
const compact = electronMocks.handlers.get(ipcChannels.agentCompactConversation)!
|
|
let release!: () => void, entered = false
|
|
const gate = new Promise<void>(resolve => { release = resolve })
|
|
beforeStorageCall.set(harness.assistantDatabase, async method => { if (method === 'getConversation') { entered = true; await gate; throw new Error('read failed') } })
|
|
const pending = expect(compact(event, { requestId, conversationId, runtimeSelection: { provider: 'model' }, history: [], historyMessageIds: [] })).rejects.toThrow('read failed')
|
|
try {
|
|
await vi.waitFor(() => expect(entered).toBe(true))
|
|
await expect(harness.handler!(event, { requestId: crypto.randomUUID(), conversationId, prompt: 'overlap', knowledgeLibraryIds: [] })).rejects.toThrow('当前对话已有执行中的请求')
|
|
release()
|
|
await pending
|
|
beforeStorageCall.delete(harness.assistantDatabase)
|
|
await expect(harness.handler!(event, { requestId, conversationId, prompt: 'retry', knowledgeLibraryIds: [] })).resolves.toBeUndefined()
|
|
} finally { release(); await pending; await harness.dispose() }
|
|
})
|
|
|
|
it('reserves a Conversation while its Runtime is resolving', async () => {
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
let resolveRuntime:
|
|
| ((value: typeof runtime) => void)
|
|
| undefined
|
|
const runtimeResolution = new Promise<typeof runtime>((resolve) => {
|
|
resolveRuntime = resolve
|
|
})
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(() => runtimeResolution)
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000724'
|
|
const firstRun = harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
{
|
|
requestId: '00000000-0000-4000-8000-000000000728',
|
|
conversationId,
|
|
runtimeSelection: { provider: 'model' },
|
|
prompt: '等待 Runtime',
|
|
knowledgeLibraryIds: []
|
|
}
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
|
|
)
|
|
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000729',
|
|
conversationId,
|
|
runtimeSelection: { provider: 'model' },
|
|
prompt: '不能并发',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
).rejects.toThrow('当前对话已有执行中的请求')
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
|
|
await expect(Promise.resolve().then(() =>
|
|
harness.branchHandler?.(trustedEvent(harness.webContents), {
|
|
sourceConversationId: conversationId,
|
|
title: '等待 Runtime · 分支'
|
|
}))).rejects.toThrow('当前会话仍有正在执行的请求')
|
|
expect(
|
|
harness.assistantDatabase.branchLocalConversation
|
|
).not.toHaveBeenCalled()
|
|
|
|
resolveRuntime?.(runtime)
|
|
await firstRun
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('interrupts the active response and promotes the selected queue item', async () => {
|
|
const runtimeStarted = vi.fn()
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(
|
|
request: { requestId: string },
|
|
signal: AbortSignal
|
|
) {
|
|
runtimeStarted(request.requestId)
|
|
await new Promise<void>((resolve) => {
|
|
signal.addEventListener('abort', () => resolve(), {
|
|
once: true
|
|
})
|
|
})
|
|
if (signal.aborted) {
|
|
throw signal.reason
|
|
}
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000725'
|
|
const item = {
|
|
id: '00000000-0000-4000-8000-000000000726',
|
|
conversationId,
|
|
source: 'user' as const,
|
|
label: '优先执行',
|
|
createdAt: '2026-08-20T09:01:00.000Z'
|
|
}
|
|
const input = {
|
|
conversationId,
|
|
runtimeSelection: { provider: 'model' as const },
|
|
includeMemoryContext: true,
|
|
prompt: item.label,
|
|
attachments: [],
|
|
knowledgeLibraryIds: [],
|
|
knowledgeRetrievalMode: 'auto' as const
|
|
}
|
|
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
|
|
item
|
|
)
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({
|
|
source: 'user',
|
|
item,
|
|
payloadJson: JSON.stringify(input)
|
|
})
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000727',
|
|
conversationId,
|
|
prompt: '当前回复',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueReady
|
|
)?.(trustedEvent(harness.webContents), conversationId)
|
|
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueInterruptAndRun
|
|
)?.(trustedEvent(harness.webContents), item.id)
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch,
|
|
{ item, input }
|
|
)
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.claimConversationQueueItem
|
|
).toHaveBeenCalledWith(conversationId, item.id)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('rejects unconfirmed image capability before accepting a queued input', async () => {
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) { yield { requestId: request.requestId, type: 'done' } as const }
|
|
})
|
|
harness.contextManager.hasImageInputs.mockReturnValue(true)
|
|
try {
|
|
await expect(electronMocks.handlers.get(ipcChannels.conversationQueueEnqueueUser)?.(trustedEvent(harness.webContents), {
|
|
conversationId: '00000000-0000-4000-8000-000000000731',
|
|
prompt: 'Read the picture',
|
|
attachments: [{ id: '00000000-0000-4000-8000-000000000734', name: 'image.png', size: 10, preview: '', kind: 'image' }],
|
|
knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto'
|
|
})).rejects.toThrow('图片输入')
|
|
expect(harness.assistantDatabase.enqueueConversationUserInput).not.toHaveBeenCalled()
|
|
expect(harness.contextManager.remove).not.toHaveBeenCalled()
|
|
} finally { await harness.dispose() }
|
|
})
|
|
|
|
it('claims an idle user message before notifying the renderer and accepts its dispatch', async () => {
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000731'
|
|
const itemId = '00000000-0000-4000-8000-000000000732'
|
|
const attachmentId =
|
|
'00000000-0000-4000-8000-000000000734'
|
|
const input = {
|
|
conversationId,
|
|
runtimeSelection: { provider: 'model' as const },
|
|
includeMemoryContext: true,
|
|
prompt: '排队发送',
|
|
attachments: [
|
|
{
|
|
id: attachmentId,
|
|
name: 'queued.txt',
|
|
size: 6,
|
|
preview: 'queued',
|
|
kind: 'text' as const
|
|
}
|
|
],
|
|
knowledgeLibraryIds: [],
|
|
knowledgeRetrievalMode: 'auto' as const
|
|
}
|
|
const item = {
|
|
id: itemId,
|
|
conversationId,
|
|
source: 'user' as const,
|
|
label: input.prompt,
|
|
createdAt: '2026-08-19T00:01:00.000Z'
|
|
}
|
|
harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue(
|
|
item
|
|
)
|
|
harness.assistantDatabase.listConversationQueueItems
|
|
.mockReturnValueOnce([item])
|
|
.mockReturnValue([])
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce({
|
|
source: 'user',
|
|
item,
|
|
payloadJson: JSON.stringify({
|
|
input,
|
|
serializedContexts: '[]'
|
|
})
|
|
})
|
|
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
|
|
item
|
|
)
|
|
|
|
const enqueueHandler = electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueEnqueueUser
|
|
)
|
|
harness.webContents.send.mockClear()
|
|
expect(
|
|
await enqueueHandler?.(trustedEvent(harness.webContents), input)
|
|
).toEqual(item)
|
|
await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch,
|
|
{ item, input }
|
|
))
|
|
const queueChangeIndex = harness.webContents.send.mock.calls.findIndex(
|
|
([channel]) => channel === ipcChannels.conversationQueueChanged
|
|
)
|
|
expect(queueChangeIndex).toBeGreaterThanOrEqual(0)
|
|
expect(
|
|
harness.assistantDatabase.claimConversationQueueItem.mock.invocationCallOrder[0]
|
|
).toBeLessThan(
|
|
harness.webContents.send.mock.invocationCallOrder[queueChangeIndex] ?? 0
|
|
)
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000733',
|
|
conversationId,
|
|
queueItemId: itemId,
|
|
runtimeSelection: input.runtimeSelection,
|
|
prompt: input.prompt,
|
|
knowledgeLibraryIds: []
|
|
})
|
|
|
|
expect(
|
|
harness.assistantDatabase.completeConversationUserQueueItem
|
|
).toHaveBeenCalledWith(itemId)
|
|
expect(harness.contextManager.serializeForQueue).toHaveBeenCalledWith([
|
|
attachmentId
|
|
])
|
|
expect(harness.contextManager.remove).toHaveBeenCalledWith(
|
|
attachmentId
|
|
)
|
|
expect(harness.contextManager.restoreFromQueue).toHaveBeenCalledWith(
|
|
'[]'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('releases the queue reservation when a dispatched run cannot start', async () => {
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000745'
|
|
const itemId = '00000000-0000-4000-8000-000000000746'
|
|
const input = {
|
|
conversationId,
|
|
runtimeSelection: { provider: 'model' as const },
|
|
includeMemoryContext: true,
|
|
prompt: '需要重新排队',
|
|
attachments: [],
|
|
knowledgeLibraryIds: [],
|
|
knowledgeRetrievalMode: 'auto' as const
|
|
}
|
|
const item = {
|
|
id: itemId,
|
|
conversationId,
|
|
source: 'user' as const,
|
|
label: input.prompt,
|
|
createdAt: '2026-08-21T00:02:00.000Z'
|
|
}
|
|
harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue(
|
|
item
|
|
)
|
|
harness.assistantDatabase.listConversationQueueItems
|
|
.mockReturnValueOnce([item])
|
|
.mockReturnValue([])
|
|
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce(
|
|
{
|
|
source: 'user',
|
|
item,
|
|
payloadJson: JSON.stringify({
|
|
input,
|
|
serializedContexts: '[]'
|
|
})
|
|
}
|
|
)
|
|
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
|
|
item
|
|
)
|
|
harness.assistantDatabase.createTask.mockImplementationOnce(() => {
|
|
throw new Error('会话记录不可用')
|
|
})
|
|
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueEnqueueUser
|
|
)?.(trustedEvent(harness.webContents), input)
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch,
|
|
{ item, input }
|
|
)
|
|
)
|
|
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000747',
|
|
conversationId,
|
|
queueItemId: itemId,
|
|
runtimeSelection: input.runtimeSelection,
|
|
prompt: input.prompt,
|
|
knowledgeLibraryIds: []
|
|
})
|
|
).rejects.toThrow('会话记录不可用')
|
|
|
|
expect(
|
|
harness.assistantDatabase.releaseConversationUserQueueItem
|
|
).toHaveBeenCalledWith(itemId)
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000748',
|
|
conversationId,
|
|
runtimeSelection: { provider: 'model' },
|
|
prompt: '失败后的新消息',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('dispatches a message queued during a run that later fails', async () => {
|
|
let failRun: ((error: Error) => void) | undefined
|
|
const runtimeStarted = vi.fn()
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: false,
|
|
// eslint-disable-next-line require-yield
|
|
async *run(request: { requestId: string }) {
|
|
runtimeStarted(request.requestId)
|
|
throw await new Promise<Error>((resolve) => {
|
|
failRun = resolve
|
|
})
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const conversationId =
|
|
'00000000-0000-4000-8000-000000000751'
|
|
const createItem = (id: string, prompt: string) => {
|
|
const input = {
|
|
conversationId,
|
|
runtimeSelection: { provider: 'model' as const },
|
|
includeMemoryContext: true,
|
|
prompt,
|
|
attachments: [],
|
|
knowledgeLibraryIds: [],
|
|
knowledgeRetrievalMode: 'auto' as const
|
|
}
|
|
return {
|
|
input,
|
|
item: {
|
|
id,
|
|
conversationId,
|
|
source: 'user' as const,
|
|
label: prompt,
|
|
createdAt: '2026-08-21T00:03:00.000Z'
|
|
}
|
|
}
|
|
}
|
|
const first = createItem(
|
|
'00000000-0000-4000-8000-000000000752',
|
|
'会失败的消息'
|
|
)
|
|
const second = createItem(
|
|
'00000000-0000-4000-8000-000000000753',
|
|
'失败之后的新消息'
|
|
)
|
|
harness.assistantDatabase.enqueueConversationUserInput
|
|
.mockReturnValueOnce(first.item)
|
|
.mockReturnValueOnce(second.item)
|
|
harness.assistantDatabase.listConversationQueueItems
|
|
.mockReturnValueOnce([first.item])
|
|
.mockReturnValueOnce([second.item])
|
|
.mockReturnValue([])
|
|
harness.assistantDatabase.claimConversationQueueItem
|
|
.mockReturnValueOnce({
|
|
source: 'user',
|
|
item: first.item,
|
|
payloadJson: JSON.stringify({
|
|
input: first.input,
|
|
serializedContexts: '[]'
|
|
})
|
|
})
|
|
.mockReturnValueOnce({
|
|
source: 'user',
|
|
item: second.item,
|
|
payloadJson: JSON.stringify({
|
|
input: second.input,
|
|
serializedContexts: '[]'
|
|
})
|
|
})
|
|
harness.assistantDatabase.getConversationQueueItem.mockImplementation(
|
|
(id: string) =>
|
|
id === first.item.id ? first.item : second.item
|
|
)
|
|
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueEnqueueUser
|
|
)?.(trustedEvent(harness.webContents), first.input)
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch,
|
|
{ item: first.item, input: first.input }
|
|
)
|
|
)
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '00000000-0000-4000-8000-000000000754',
|
|
conversationId,
|
|
queueItemId: first.item.id,
|
|
runtimeSelection: first.input.runtimeSelection,
|
|
prompt: first.input.prompt,
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
|
|
|
|
// The next message arrives while the run is still in flight, so the
|
|
// renderer never reports readiness for it afterwards.
|
|
electronMocks.handlers.get(
|
|
ipcChannels.conversationQueueEnqueueUser
|
|
)?.(trustedEvent(harness.webContents), second.input)
|
|
failRun?.(new Error('模型接口请求失败'))
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(
|
|
'00000000-0000-4000-8000-000000000754',
|
|
'failed',
|
|
expect.any(String)
|
|
)
|
|
)
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.conversationQueueDispatch,
|
|
{ item: second.item, input: second.input }
|
|
)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('publishes Runtime usage as context metrics with one settings read', async () => {
|
|
const runtime = {
|
|
runtimeId: 'continue',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
for (const [index, inputTokens] of [100, 120].entries()) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'model-usage',
|
|
callId: `continue-call-${index}`,
|
|
runtime: 'continue',
|
|
provider: 'anthropic',
|
|
model: 'summary-model',
|
|
inputTokens,
|
|
outputTokens: 20,
|
|
cacheReadTokens: 10,
|
|
cacheWriteTokens: 5
|
|
} as const
|
|
}
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
harness.getResolvedSettings.mockResolvedValue({
|
|
subagentSmartRoutingEnabled: false,
|
|
continueModelProfile: {
|
|
contextWindowTokens: 32_000
|
|
},
|
|
contextCompression: {
|
|
triggerTokens: 20_000
|
|
}
|
|
})
|
|
const requestId = '00000000-0000-4000-8000-000000000020'
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'continue-context',
|
|
prompt: 'report context usage',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
const metrics = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, event]) => event)
|
|
.filter(
|
|
(event): event is AgentEvent =>
|
|
(event as AgentEvent).type === 'context-metrics'
|
|
)
|
|
expect(metrics).toEqual([
|
|
{
|
|
requestId,
|
|
type: 'context-metrics',
|
|
contextTokens: 115,
|
|
effectiveTriggerTokens: 32_000,
|
|
contextWindowTokens: 32_000,
|
|
compressionEnabled: false,
|
|
source: 'provider',
|
|
basis: 'model-call'
|
|
},
|
|
{
|
|
requestId,
|
|
type: 'context-metrics',
|
|
contextTokens: 135,
|
|
effectiveTriggerTokens: 32_000,
|
|
contextWindowTokens: 32_000,
|
|
compressionEnabled: false,
|
|
source: 'provider',
|
|
basis: 'model-call'
|
|
}
|
|
])
|
|
expect(harness.getResolvedSettings).toHaveBeenCalledOnce()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('rejects unknown knowledge scope and creates no capability for empty scope', async () => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'capability'),
|
|
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{
|
|
database: {
|
|
listKnowledgeBases: vi.fn(() => [
|
|
{ id: libraryId, name: 'Known' }
|
|
])
|
|
}
|
|
},
|
|
knowledgeGateway
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
|
|
await expect(
|
|
harness.handler?.(event, {
|
|
requestId: '00000000-0000-4000-8000-000000000021',
|
|
conversationId: 'unknown-scope',
|
|
prompt: 'test',
|
|
knowledgeLibraryIds: [
|
|
'22222222-2222-4222-8222-222222222222'
|
|
]
|
|
})
|
|
).rejects.toThrow('不存在的知识库')
|
|
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
|
|
|
|
await harness.handler?.(event, {
|
|
requestId: '00000000-0000-4000-8000-000000000022',
|
|
conversationId: 'empty-scope',
|
|
prompt: 'test',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
'00000000-0000-4000-8000-000000000022',
|
|
'completed'
|
|
)
|
|
)
|
|
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('retains user-enabled knowledge scopes when other applications are disabled', async () => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
let finish: (() => void) | undefined
|
|
const held = new Promise<void>(resolve => { finish = resolve })
|
|
const run = vi.fn(async function* (request: { requestId: string; knowledgeLibraryIds: string[] }) {
|
|
if (request.requestId.endsWith('21')) await held
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
})
|
|
const retrieveMany = vi.fn(async () => [])
|
|
const gateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => ['knowledge_search']), drainReferences: vi.fn(() => []), revoke: vi.fn() }
|
|
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, supportsScopedDataTools: true, run }, undefined, undefined, false, undefined, {
|
|
database: { listKnowledgeBases: vi.fn(() => [{ id: libraryId, name: 'Knowledge' }]) }, retrieveMany
|
|
}, gateway)
|
|
const request = { prompt: 'use saved knowledge', knowledgeLibraryIds: [libraryId], knowledgeRetrievalMode: 'always' }
|
|
try {
|
|
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000021', conversationId: 'in-flight' })
|
|
await vi.waitFor(() => expect(run).toHaveBeenCalledOnce())
|
|
expect(retrieveMany).toHaveBeenCalledOnce()
|
|
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: false, magicNotesEnabled: false })
|
|
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000022', conversationId: 'disabled' })
|
|
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2))
|
|
expect(run.mock.calls[0]![0].knowledgeLibraryIds).toEqual([libraryId])
|
|
expect(run.mock.calls[1]![0].knowledgeLibraryIds).toEqual([libraryId])
|
|
expect(retrieveMany).toHaveBeenCalledTimes(2)
|
|
expect(gateway.grant).toHaveBeenCalledTimes(2)
|
|
expect(harness.assistantDatabase.updateTaskStatus).not.toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'cancelled')
|
|
finish!()
|
|
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'completed'))
|
|
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: true, magicNotesEnabled: false })
|
|
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000023', conversationId: 'restored' })
|
|
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(3))
|
|
expect(run.mock.calls[2]![0].knowledgeLibraryIds).toEqual([libraryId])
|
|
expect(retrieveMany).toHaveBeenCalledTimes(3)
|
|
expect(request.knowledgeLibraryIds).toEqual([libraryId])
|
|
} finally {
|
|
finish!()
|
|
await harness.dispose()
|
|
}
|
|
})
|
|
|
|
it.skipIf(!process.env.GOODBUDDY_NATIVE_CLIENT_INTEGRATION)('opens and stops official native clients from the real UI through preload, IPC and saved project settings', async () => {
|
|
const { createElement } = await import('react')
|
|
const { render, fireEvent, waitFor, cleanup } = await import('@testing-library/react/pure')
|
|
const rendererModule = '../renderer/src/RuntimeNativeClientActions'
|
|
const { RuntimeNativeClientActions } = await import(rendererModule) as {
|
|
RuntimeNativeClientActions: import('react').ComponentType<{
|
|
browser: boolean; contextKey: string; conversationId: string; prepareConversation: () => Promise<string>
|
|
onTerminal: (value: import('../shared/terminal-contracts').TerminalSnapshot) => void; notify: (value: unknown) => void
|
|
}>
|
|
}
|
|
const { default: i18n } = await import('i18next')
|
|
const { NativeClientCoordinator } = await import('./agent/native-client-coordinator')
|
|
const { RuntimeSettingsStore } = await import('./runtime-settings-store')
|
|
const { ApplicationSettingsStore } = await import('./application-settings-store')
|
|
const { ExecutionSpaceResolver } = await import('./execution-space/execution-space-resolver')
|
|
const { KnowledgeMcpGateway } = await import('./agent/knowledge-mcp-gateway')
|
|
const { TerminalSessionManager } = await import('./terminal/terminal-session-manager')
|
|
const { resolveNpmCliPaths } = await import('./local-tool-environment')
|
|
const { resolveBundledRuntimePaths } = await import('./agent/bundled-runtimes')
|
|
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-native-ui-'))
|
|
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
|
|
const liveModule = process.env.GOODBUDDY_NATIVE_CLIENT_LIVE_MODULE
|
|
const { createRequire } = await import('node:module')
|
|
const live = liveModule ? createRequire(import.meta.url)(liveModule) as {
|
|
begin: (fetcher: typeof fetch) => typeof fetch
|
|
profile: (runtime: string) => import('./runtime-settings-store').ResolvedModelProfile
|
|
web: (url: string, workspace: string) => Promise<void>
|
|
terminal: (runtime: string, write: (data: string) => Promise<unknown>, output: () => string) => Promise<void>
|
|
finish: () => Promise<void>
|
|
} : undefined
|
|
if (live) vi.stubGlobal('fetch', live.begin(fetch))
|
|
let coordinator: InstanceType<typeof NativeClientCoordinator> | undefined
|
|
let harness: ReturnType<typeof createHarness> | undefined
|
|
let terminalManager: InstanceType<typeof TerminalSessionManager> | undefined
|
|
try {
|
|
database.initialize(root)
|
|
const secrets = new Map<string, string>()
|
|
const cipher = { isAvailable: () => true,
|
|
encrypt: (value: string) => { const id = crypto.randomUUID(); secrets.set(id, value); return Buffer.from(id) },
|
|
decrypt: (value: Buffer) => secrets.get(value.toString())! }
|
|
const settingsStore = new RuntimeSettingsStore(join(root, 'settings.json'), cipher, {})
|
|
const profileId = '00000000-0000-4000-8000-000000000221'
|
|
const inputDefaults = { ...defaultRuntimeSettings } as Record<string, unknown>
|
|
delete inputDefaults.supportsImageInput
|
|
const selectedProfile = live?.profile('deepseek-harness')
|
|
await settingsStore.update(runtimeSettingsInputSchema.parse({ ...inputDefaults, apiKey: { action: 'keep' },
|
|
provider: 'deepseek-harness', workspacePath: root,
|
|
modelProfiles: [{ name: 'Native UI Test', baseUrl: 'http://127.0.0.1:1', modelName: 'native-ui-model',
|
|
protocol: 'openai-chat-completions', authentication: 'api-key', imageGenerationQuality: 'auto',
|
|
...selectedProfile, id: profileId, apiKey: { action: 'replace', value: selectedProfile?.apiKey ?? 'native-ui-secret' } }],
|
|
defaultModelProfileId: profileId, deepseekHarnessModelSource: { kind: 'profile', profileId }
|
|
}))
|
|
const project = database.createProject({ name: 'Native UI', description: '', rootPath: root,
|
|
runtimeSelection: { provider: 'deepseek-harness', model: { kind: 'profile', profileId } } })
|
|
const conversationId = '00000000-0000-4000-8000-000000000222'
|
|
const now = Date.now()
|
|
const save = async (): Promise<string> => {
|
|
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Native UI', updatedAt: now }, messages: [] }])
|
|
return conversationId
|
|
}
|
|
await save()
|
|
const capabilities = new CapabilityService(join(root, 'capabilities.json'), join(root, 'builtin'), join(root, 'imported'), cipher)
|
|
const opened: string[] = []
|
|
const terminalEvents: import('../shared/terminal-contracts').TerminalEvent[] = []
|
|
terminalManager = new TerminalSessionManager({ database, executionSpaceResolver: new ExecutionSpaceResolver(),
|
|
targetResolver: {} as never, sshPool: {} as never, remoteEnabled: () => false,
|
|
deliverEvent: (ownerId, event) => {
|
|
expect(ownerId).toBe(9)
|
|
terminalEvents.push(event)
|
|
terminalManager!.acknowledge(ownerId, event.sessionId, event.sequence)
|
|
}
|
|
})
|
|
const applicationSettingsStore = new ApplicationSettingsStore(join(root, 'application.json'))
|
|
if (process.env.NATIVE_DSH_TEST_NODE) await applicationSettingsStore.update({
|
|
localToolEnvironment: { ...(await applicationSettingsStore.get()).localToolEnvironment,
|
|
node: { source: 'custom', executablePath: process.env.NATIVE_DSH_TEST_NODE } }
|
|
})
|
|
coordinator = new NativeClientCoordinator({ database, settingsStore,
|
|
openModelCallLedger: async path => new AgentModelCallLedger(path),
|
|
applicationSettingsStore,
|
|
capabilities, executionSpaceResolver: new ExecutionSpaceResolver(), terminalManager,
|
|
localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady: async () => undefined } as never,
|
|
rootDirectory: join(root, 'clients'), managedNodeDirectory: join(root, 'node'),
|
|
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
|
|
npmCliPath: resolveNpmCliPaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }).npmCliPath,
|
|
createGateway: () => new KnowledgeMcpGateway({} as never),
|
|
openExternal: async (url) => { opened.push(url) }
|
|
})
|
|
harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
|
|
false, undefined, undefined, undefined, undefined, undefined, false, undefined, coordinator, terminalManager)
|
|
const event = trustedEvent(harness.webContents)
|
|
electronMocks.invoke.mockImplementation((channel: string, input: unknown) => electronMocks.handlers.get(channel)!(event, input))
|
|
await import('../preload/index')
|
|
const notify = vi.fn()
|
|
const view = render(createElement(RuntimeNativeClientActions, { browser: true, contextKey: project.id,
|
|
conversationId, prepareConversation: save, onTerminal: vi.fn(), notify }))
|
|
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.browser') }))
|
|
await waitFor(() => {
|
|
expect(notify).not.toHaveBeenCalled()
|
|
expect(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.reopen') })).toBeEnabled()
|
|
}, { timeout: 120_000 })
|
|
expect(opened).toHaveLength(1)
|
|
const handle = await coordinator.get(9, conversationId)
|
|
expect(handle?.serviceId).toBeTruthy()
|
|
expect(await coordinator.get(10, conversationId)).toBeNull()
|
|
await coordinator.stop(10, handle!.serviceId)
|
|
expect(await coordinator.get(9, conversationId)).toEqual(handle)
|
|
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Native UI', updatedAt: now + 1 }, messages: [] }])
|
|
expect(await coordinator.get(9, conversationId)).toBeNull()
|
|
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Native UI', updatedAt: now + 2 }, messages: [] }])
|
|
expect(await coordinator.get(9, conversationId)).toEqual(handle)
|
|
const config = await readFile(join(root, 'clients', 'dsh', handle!.serviceId, 'cordis.patch.yml'), 'utf8')
|
|
expect(config).toContain(selectedProfile?.modelName ?? 'native-ui-model')
|
|
expect(config).not.toContain('native-ui-secret')
|
|
if (selectedProfile?.apiKey) expect(config).not.toContain(selectedProfile.apiKey)
|
|
const auth = await fetch(opened[0]!, { redirect: 'manual' })
|
|
expect(auth.status).toBe(303)
|
|
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
|
|
const page = await fetch(new URL('/', opened[0]!), { headers: { cookie } })
|
|
expect(page.status).toBe(200)
|
|
expect(await page.text()).toContain('<html')
|
|
await live?.web(opened[0]!, root)
|
|
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.reopen') }))
|
|
await waitFor(() => expect(opened).toHaveLength(2))
|
|
expect(await coordinator.get(9, conversationId)).toEqual(handle)
|
|
await waitFor(() => expect(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.stop') })).toBeEnabled())
|
|
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.stop') }))
|
|
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.confirmStop') }))
|
|
await waitFor(() => expect(view.queryByRole('button', { name: i18n.t('app:composer.nativeClient.stop') })).not.toBeInTheDocument(), { timeout: 10_000 })
|
|
expect(await coordinator.get(9, conversationId)).toBeNull()
|
|
await expect(fetch(new URL('/', opened[0]!))).rejects.toThrow()
|
|
|
|
const terminals: import('../shared/terminal-contracts').TerminalSnapshot[] = []
|
|
for (const provider of ['continue', 'opencode'] as const) {
|
|
if (live) {
|
|
const profile = live.profile(provider)
|
|
await settingsStore.update(runtimeSettingsInputSchema.parse({ ...inputDefaults, apiKey: { action: 'keep' },
|
|
provider, workspacePath: root, defaultModelProfileId: profileId,
|
|
modelProfiles: [{ ...profile, id: profileId, imageGenerationQuality: 'auto', apiKey: { action: 'replace', value: profile.apiKey } }],
|
|
[`${provider}ModelSource`]: { kind: 'profile', profileId }
|
|
}))
|
|
}
|
|
database.updateProject(project.id, { ...project, runtimeSelection: { provider, model: { kind: 'profile', profileId } } })
|
|
const onTerminal = vi.fn<(value: import('../shared/terminal-contracts').TerminalSnapshot) => void>()
|
|
notify.mockClear()
|
|
view.rerender(createElement(RuntimeNativeClientActions, { browser: false, contextKey: `${project.id}:${provider}`,
|
|
conversationId, prepareConversation: save, onTerminal, notify }))
|
|
fireEvent.click(view.getByRole('button', { name: i18n.t('app:composer.nativeClient.terminal') }))
|
|
await waitFor(() => {
|
|
expect(notify).not.toHaveBeenCalled()
|
|
expect(onTerminal).toHaveBeenCalledWith(expect.objectContaining({ state: 'running', workingDirectory: root }))
|
|
}, { timeout: 30_000 })
|
|
const terminal = onTerminal.mock.calls[0]![0]
|
|
terminals.push(terminal)
|
|
await waitFor(() => expect(terminalEvents.some(event => event.sessionId === terminal.sessionId && event.type === 'output' && /Continue|OpenCode|opencode|Native UI Test|native-ui-model/u.test(event.data))).toBe(true), { timeout: 20_000 })
|
|
await live?.terminal(provider,
|
|
data => (globalThis as unknown as { goodbuddy: import('../shared/contracts').DesktopApi }).goodbuddy.terminal.write({ sessionId: terminal.sessionId, data }),
|
|
() => terminalEvents.flatMap(event => event.sessionId === terminal.sessionId && event.type === 'output' ? [event.data] : []).join(''))
|
|
}
|
|
await harness.dispose()
|
|
harness = undefined
|
|
for (const terminal of terminals) expect(() => terminalManager!.snapshot(9, terminal.sessionId)).toThrow()
|
|
} finally {
|
|
cleanup()
|
|
vi.unstubAllGlobals()
|
|
await harness?.dispose()
|
|
await coordinator?.dispose()
|
|
await terminalManager?.dispose()
|
|
database.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
await live?.finish()
|
|
}
|
|
}, 360_000)
|
|
|
|
it('tests an unsaved Obsidian UI draft through registered IPC and bundled MCPVault, then saves and refreshes', async () => {
|
|
const { createElement } = await import('react')
|
|
const { render, fireEvent, waitFor, cleanup } = await import('@testing-library/react/pure')
|
|
// Load the renderer at test runtime without pulling its JSX project into the Node typecheck.
|
|
const rendererModule = '../renderer/src/McpSettingsSection'
|
|
const { McpSettingsSection } = await import(rendererModule) as {
|
|
McpSettingsSection: import('react').ComponentType<{ onOpenImageModelSettings: () => void }>
|
|
}
|
|
const { default: i18n } = await import('i18next')
|
|
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-obsidian-'))
|
|
let harness: ReturnType<typeof createHarness> | undefined
|
|
try {
|
|
const vaultPath = join(root, 'Draft Vault')
|
|
await mkdir(vaultPath)
|
|
await writeFile(join(vaultPath, 'existing.md'), '# Keep this note\n')
|
|
const appPath = join(root, 'app.asar')
|
|
await packageObsidianMcpVault(process.cwd(), join(root, 'app.asar.unpacked', 'out', 'main', 'obsidian-mcpvault'))
|
|
const service = new ObsidianService({
|
|
appPath,
|
|
launchEnvironmentProvider: () => ({ PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}` })
|
|
})
|
|
const capabilities = new CapabilityService(
|
|
join(root, 'capabilities.json'), join(root, 'builtin'), join(root, 'imported'),
|
|
{ isAvailable: () => true, encrypt: (value) => Buffer.from(value), decrypt: (value) => value.toString() }
|
|
)
|
|
harness = createHarness(
|
|
{ capability: 'chat' }, undefined, undefined, false,
|
|
undefined, undefined, undefined, false, undefined,
|
|
{
|
|
getSnapshot: capabilities.getSnapshot.bind(capabilities),
|
|
updateObsidianSettings: capabilities.updateObsidianSettings.bind(capabilities)
|
|
},
|
|
undefined, undefined, undefined, undefined, service
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
const test = electronMocks.handlers.get(ipcChannels.capabilitiesTestObsidianConnection)!
|
|
const save = electronMocks.handlers.get(ipcChannels.capabilitiesUpdateObsidianSettings)!
|
|
const pick = electronMocks.handlers.get(ipcChannels.capabilitiesSelectObsidianVault)!
|
|
expect(test).toBeTypeOf('function')
|
|
expect(save).toBeTypeOf('function')
|
|
expect(pick).toBeTypeOf('function')
|
|
|
|
// Replace only Electron transport; UI, handlers, persistence and MCPVault are real.
|
|
const invoke = async (channel: string, input?: unknown) =>
|
|
electronMocks.handlers.get(channel)!(event, input)
|
|
vi.stubGlobal('goodbuddy', { capabilities: {
|
|
getSnapshot: () => invoke(ipcChannels.capabilitiesSnapshot),
|
|
testObsidianConnection: (input: unknown) => invoke(ipcChannels.capabilitiesTestObsidianConnection, input),
|
|
updateObsidianSettings: (input: unknown) => invoke(ipcChannels.capabilitiesUpdateObsidianSettings, input),
|
|
selectObsidianVault: () => invoke(ipcChannels.capabilitiesSelectObsidianVault)
|
|
} })
|
|
const view = render(createElement(McpSettingsSection, { onOpenImageModelSettings: vi.fn() }))
|
|
const testButton = view.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') })
|
|
await waitFor(() => expect(testButton).toBeEnabled())
|
|
fireEvent.change(view.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') }), { target: { value: 'folder' } })
|
|
const pathInput = view.getByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })
|
|
fireEvent.change(pathInput, { target: { value: vaultPath } })
|
|
fireEvent.click(testButton)
|
|
await waitFor(() => {
|
|
expect(view.getByText(vaultPath)).toBeVisible()
|
|
expect(testButton).toBeEnabled()
|
|
}, { timeout: 10_000 })
|
|
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath: '' })
|
|
expect(harness.onRuntimeSettingsChanged).not.toHaveBeenCalled()
|
|
expect(await readdir(vaultPath)).toEqual(['existing.md'])
|
|
expect(await readFile(join(vaultPath, 'existing.md'), 'utf8')).toBe('# Keep this note\n')
|
|
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: true, filePaths: [join(root, 'Cancelled Vault')] })
|
|
const pickButton = view.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.select') })
|
|
fireEvent.click(pickButton)
|
|
await waitFor(() => {
|
|
expect(electronMocks.showOpenDialog).toHaveBeenCalledOnce()
|
|
expect(pickButton).toBeEnabled()
|
|
expect(pathInput).toHaveValue(vaultPath)
|
|
})
|
|
electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: [vaultPath] })
|
|
await expect(pick(event)).resolves.toBe(vaultPath)
|
|
expect(electronMocks.showOpenDialog).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ webContents: harness.webContents }), { properties: ['openDirectory'] }
|
|
)
|
|
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath: '' })
|
|
|
|
fireEvent.click(view.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') }))
|
|
await waitFor(() => {
|
|
expect(view.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: vaultPath }))).toBeVisible()
|
|
expect(testButton).toBeEnabled()
|
|
})
|
|
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
harness.onRuntimeSettingsChanged.mockClear()
|
|
await expect(save(event, { vaultPath: ` ${vaultPath} ` })).resolves.toMatchObject({
|
|
obsidian: { vaultPath },
|
|
builtinMcpServers: expect.arrayContaining([expect.objectContaining({ id: 'obsidian', enabled: false })])
|
|
})
|
|
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath })
|
|
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
await expect(test(event, { vaultPath: join(root, 'missing') })).rejects.toThrow('missing or inaccessible')
|
|
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath })
|
|
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
await expect(Promise.resolve().then(() => save(event, { vaultPath: 42 }))).rejects.toThrow()
|
|
await expect(test(event, { vaultPath: 42 })).rejects.toThrow()
|
|
const untrustedEvent = { ...event, sender: { ...harness.webContents, id: 99 } }
|
|
await expect(Promise.resolve().then(() => save(untrustedEvent, { vaultPath }))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await expect(test(untrustedEvent, { vaultPath })).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await expect(pick(untrustedEvent)).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
} finally {
|
|
cleanup()
|
|
vi.unstubAllGlobals()
|
|
await harness?.dispose()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
}, 60_000)
|
|
|
|
it.each([
|
|
{ enabled: false, assigned: true, access: undefined },
|
|
{ enabled: true, assigned: false, access: undefined },
|
|
{ enabled: true, assigned: true, access: 'write' }
|
|
])('routes Obsidian request grants with saved settings: %j', async ({ enabled, assigned, access }) => {
|
|
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-obsidian-grant-'))
|
|
let harness: ReturnType<typeof createHarness> | undefined
|
|
try {
|
|
const capabilities = new CapabilityService(
|
|
join(root, 'capabilities.json'), join(root, 'builtin'), join(root, 'imported'),
|
|
{ isAvailable: () => true, encrypt: (value) => Buffer.from(value), decrypt: (value) => value.toString() }
|
|
)
|
|
const settings = { vaultPath: join(root, 'Saved Vault') }
|
|
// This harness exercises Obsidian grants and does not install a browser service.
|
|
await capabilities.setBuiltinMcpServerEnabled('builtin-browser', false)
|
|
await capabilities.updateObsidianSettings(settings)
|
|
if (enabled) await capabilities.setBuiltinMcpServerEnabled('obsidian', true)
|
|
if (!assigned) await capabilities.setBuiltinMcpServerAssignments('obsidian', ['continue'])
|
|
const received: AgentExecutionRequest[] = []
|
|
const gateway = {
|
|
grant: vi.fn(() => 'obsidian-capability'),
|
|
getAvailableToolNames: vi.fn(() => ['obsidian_list_vaults']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const getObsidianSettings = vi.fn(() => capabilities.getObsidianSettings())
|
|
harness = createHarness({
|
|
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
|
|
async *run(request: AgentExecutionRequest) {
|
|
received.push(request)
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}, undefined, undefined, false, undefined, undefined, gateway, false, undefined, {
|
|
getEnabledBuiltinMcpServerIds: capabilities.getEnabledBuiltinMcpServerIds.bind(capabilities),
|
|
getObsidianSettings
|
|
})
|
|
const requestId = '00000000-0000-4000-8000-000000000026'
|
|
await harness.handler!(trustedEvent(harness.webContents), {
|
|
requestId, conversationId: 'obsidian-grant', prompt: 'List my vaults', knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() => expect(harness!.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
|
|
expect(received).toHaveLength(1)
|
|
if (access) {
|
|
expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(
|
|
requestId, [], expect.any(AbortSignal), 'none', undefined, undefined, undefined, undefined,
|
|
{ settings, access }
|
|
)
|
|
expect(received[0]?.knowledgeCapabilityToken).toBe('obsidian-capability')
|
|
expect(getObsidianSettings).toHaveBeenCalledOnce()
|
|
expect(gateway.revoke).toHaveBeenCalledWith('obsidian-capability')
|
|
} else {
|
|
expect(gateway.grant).not.toHaveBeenCalled()
|
|
expect(getObsidianSettings).not.toHaveBeenCalled()
|
|
expect(received[0]?.knowledgeCapabilityToken).toBeUndefined()
|
|
}
|
|
} finally {
|
|
await harness?.dispose()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it.each(['model', 'opencode', 'continue', 'deepseek-harness'])('binds Story Graph to the current project for %s and omits it when Supervisor is off', async runtimeId => {
|
|
const received: AgentExecutionRequest[] = []
|
|
const binding = { available: vi.fn(async () => true), call: vi.fn() }
|
|
const gateway = { grant: vi.fn(() => 'graph-capability'), getAvailableToolNames: vi.fn(() => ['story_graph_search', 'story_graph_get_context', 'story_graph_read_source']),
|
|
bindRemoteStoryGraph: vi.fn(() => binding), drainReferences: vi.fn(() => []), revoke: vi.fn() }
|
|
const harness = createHarness({ runtimeId, capability: 'chat', supportsToolExecution: true,
|
|
async *run(request: AgentExecutionRequest) { received.push(request); yield { requestId: request.requestId, type: 'done' } }
|
|
}, undefined, undefined, false, undefined, undefined, gateway, false, undefined, {
|
|
getEnabledBuiltinMcpServerIds: vi.fn(async () => ['story-graph'])
|
|
})
|
|
try {
|
|
const projectId = '00000000-0000-4000-8000-000000000088'
|
|
for (const [enabled, conversationEnabled] of [[true, true], [false, true], [true, false]]) {
|
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: enabled })
|
|
harness.assistantDatabase.isConversationStoryGraphEnabled.mockReturnValue(conversationEnabled!)
|
|
const requestId = crypto.randomUUID()
|
|
await harness.handler!(trustedEvent(harness.webContents), { requestId, projectId, conversationId: 'graph-grant', prompt: 'Continue work', knowledgeLibraryIds: [] })
|
|
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
|
|
}
|
|
expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(expect.any(String), [], expect.any(AbortSignal), 'none',
|
|
undefined, undefined, undefined, undefined, undefined, { projectId, conversationId: 'graph-grant', runtimeTarget: runtimeId })
|
|
expect(received[0]).toMatchObject({ knowledgeCapabilityToken: 'graph-capability', storyGraphBinding: binding })
|
|
expect(received[1]).not.toHaveProperty('storyGraphBinding')
|
|
expect(received[2]).not.toHaveProperty('storyGraphBinding')
|
|
expect(gateway.bindRemoteStoryGraph).toHaveBeenCalledOnce()
|
|
} finally { await harness.dispose() }
|
|
})
|
|
|
|
it('grants enabled Magic Notes write tools for both reading and writing requests', async () => {
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'capability'),
|
|
getAvailableToolNames: vi.fn(() => [
|
|
'note_list',
|
|
'note_get',
|
|
'note_search',
|
|
'note_create',
|
|
'note_update',
|
|
'note_entry_create',
|
|
'note_entry_update',
|
|
'note_entry_delete',
|
|
'note_delete'
|
|
]),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
true
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
const readRequestId = '00000000-0000-4000-8000-000000000023'
|
|
const writeRequestId = '00000000-0000-4000-8000-000000000024'
|
|
|
|
await harness.handler?.(event, {
|
|
requestId: readRequestId,
|
|
conversationId: 'notes-read',
|
|
prompt: '读取笔记',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
readRequestId,
|
|
'completed'
|
|
)
|
|
)
|
|
await harness.handler?.(event, {
|
|
requestId: writeRequestId,
|
|
conversationId: 'notes-write',
|
|
prompt: '创建笔记',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
writeRequestId,
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
expect(knowledgeGateway.grant).toHaveBeenNthCalledWith(
|
|
1,
|
|
readRequestId,
|
|
[],
|
|
expect.any(AbortSignal),
|
|
'write'
|
|
)
|
|
expect(knowledgeGateway.grant).toHaveBeenNthCalledWith(
|
|
2,
|
|
writeRequestId,
|
|
[],
|
|
expect.any(AbortSignal),
|
|
'write'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('does not grant a built-in MCP that is disabled or unassigned for the runtime', async () => {
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'must-not-be-granted'),
|
|
getAvailableToolNames: vi.fn(() => ['note_list']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [
|
|
'knowledge-base'
|
|
])
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
true,
|
|
undefined,
|
|
{ getEnabledBuiltinMcpServerIds }
|
|
)
|
|
const requestId = '00000000-0000-4000-8000-000000000025'
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'disabled-notes',
|
|
prompt: '读取笔记',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledWith('model')
|
|
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('grants the assigned built-in browser for ordinary requests', async () => {
|
|
const receivedRequests: Array<{
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
browserTabId?: BrowserTabId
|
|
}> = []
|
|
const runtime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
supportsScopedDataTools: true,
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
browserTabId?: BrowserTabId
|
|
}) {
|
|
receivedRequests.push(request)
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'browser-capability'),
|
|
getAvailableToolNames: vi.fn(() => [
|
|
'browser_navigate',
|
|
'browser_snapshot'
|
|
]),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [
|
|
'builtin-browser'
|
|
])
|
|
const primaryTabId = browserTabIdSchema.parse(
|
|
'00000000-0000-4000-8000-000000000401'
|
|
)
|
|
const siblingTabId = browserTabIdSchema.parse(
|
|
'00000000-0000-4000-8000-000000000402'
|
|
)
|
|
const browserControl = {
|
|
listTabs: vi.fn(() => [
|
|
{ tabId: siblingTabId, primary: false },
|
|
{ tabId: primaryTabId, primary: true }
|
|
]),
|
|
getVisibleTabId: vi.fn(() => undefined),
|
|
acquireTabUsage: vi.fn(
|
|
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
|
|
conversationId,
|
|
tabId,
|
|
owner,
|
|
signal: new AbortController().signal,
|
|
release: vi.fn()
|
|
})
|
|
),
|
|
createTab: vi.fn(),
|
|
onState: vi.fn(() => () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
false,
|
|
undefined,
|
|
{ getEnabledBuiltinMcpServerIds },
|
|
browserControl
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
const readRequestId = '00000000-0000-4000-8000-000000000026'
|
|
const writeRequestId = '00000000-0000-4000-8000-000000000027'
|
|
|
|
await harness.handler?.(event, {
|
|
requestId: readRequestId,
|
|
conversationId: 'browser-ask',
|
|
prompt: '只回答',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
readRequestId,
|
|
'completed'
|
|
)
|
|
)
|
|
await harness.handler?.(event, {
|
|
requestId: writeRequestId,
|
|
conversationId: 'browser-execute',
|
|
prompt: '打开网页',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
writeRequestId,
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledTimes(2)
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
|
readRequestId, [], expect.any(AbortSignal), 'none', undefined,
|
|
'browser-ask', primaryTabId,
|
|
expect.objectContaining({ conversationId: 'browser-ask', tabId: primaryTabId, owner: readRequestId }),
|
|
undefined, undefined
|
|
)
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
|
writeRequestId,
|
|
[],
|
|
expect.any(AbortSignal),
|
|
'none',
|
|
undefined,
|
|
'browser-execute',
|
|
primaryTabId,
|
|
expect.objectContaining({
|
|
conversationId: 'browser-execute',
|
|
tabId: primaryTabId,
|
|
owner: writeRequestId
|
|
}),
|
|
undefined,
|
|
undefined
|
|
)
|
|
expect(receivedRequests[0]).toMatchObject({ knowledgeCapabilityToken: 'browser-capability', browserTabId: primaryTabId })
|
|
expect(receivedRequests[1]).toMatchObject({
|
|
knowledgeCapabilityToken: 'browser-capability',
|
|
browserTabId: primaryTabId,
|
|
trustedInstructions: expect.stringContaining('browser_navigate')
|
|
})
|
|
expect(browserControl.listTabs).toHaveBeenCalledWith(
|
|
'browser-execute',
|
|
harness.webContents.id
|
|
)
|
|
expect(browserControl.createTab).not.toHaveBeenCalled()
|
|
expect(browserControl.acquireTabUsage).toHaveBeenCalledWith(
|
|
'browser-execute',
|
|
primaryTabId,
|
|
writeRequestId,
|
|
harness.webContents.id
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('prefers the owner window visible browser tab over the primary tab', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000028'
|
|
const primaryTabId = browserTabIdSchema.parse(
|
|
'00000000-0000-4000-8000-000000000411'
|
|
)
|
|
const visibleTabId = browserTabIdSchema.parse(
|
|
'00000000-0000-4000-8000-000000000412'
|
|
)
|
|
const received: Array<{ browserTabId?: BrowserTabId }> = []
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string; browserTabId?: BrowserTabId }) {
|
|
received.push(request)
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const release = vi.fn()
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'browser-capability'),
|
|
getAvailableToolNames: vi.fn(() => ['browser_navigate']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const browserControl = {
|
|
listTabs: vi.fn(() => [
|
|
{ tabId: primaryTabId, primary: true },
|
|
{ tabId: visibleTabId, primary: false }
|
|
]),
|
|
getVisibleTabId: vi.fn(() => visibleTabId),
|
|
acquireTabUsage: vi.fn(
|
|
(conversationId: string, tabId: BrowserTabId, owner: string) => ({
|
|
conversationId,
|
|
tabId,
|
|
owner,
|
|
signal: new AbortController().signal,
|
|
release
|
|
})
|
|
),
|
|
createTab: vi.fn(),
|
|
onState: vi.fn(() => () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
false,
|
|
undefined,
|
|
{ getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) },
|
|
browserControl
|
|
)
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'visible-browser',
|
|
prompt: '使用当前网页',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
expect(received[0]?.browserTabId).toBe(visibleTabId)
|
|
expect(browserControl.acquireTabUsage).toHaveBeenCalledWith(
|
|
'visible-browser',
|
|
visibleTabId,
|
|
requestId,
|
|
harness.webContents.id
|
|
)
|
|
expect(browserControl.createTab).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('completes the runtime request and releases its lease after browser lease abort', async () => {
|
|
const requestId = '00000000-0000-4000-8000-000000000031'
|
|
const tabId = browserTabIdSchema.parse(
|
|
'00000000-0000-4000-8000-000000000423'
|
|
)
|
|
const leaseController = new AbortController()
|
|
const release = vi.fn()
|
|
const lease = {
|
|
conversationId: 'browser-lease-abort',
|
|
tabId,
|
|
owner: requestId,
|
|
signal: leaseController.signal,
|
|
release
|
|
}
|
|
let runtimeSignal: AbortSignal | undefined
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }, signal: AbortSignal) {
|
|
runtimeSignal = signal
|
|
leaseController.abort(new Error('browser tab closed'))
|
|
signal.throwIfAborted()
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
let grantedLease: { release(): void } | undefined
|
|
const knowledgeGateway = {
|
|
grant: vi.fn((...args: unknown[]) => {
|
|
grantedLease = args[7] as { release(): void }
|
|
return 'browser-capability'
|
|
}),
|
|
getAvailableToolNames: vi.fn(() => ['browser_navigate']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn(() => grantedLease?.release())
|
|
}
|
|
const browserControl = {
|
|
listTabs: vi.fn(() => [{ tabId, primary: true }]),
|
|
getVisibleTabId: vi.fn(() => tabId),
|
|
acquireTabUsage: vi.fn(() => lease),
|
|
createTab: vi.fn(),
|
|
onState: vi.fn(() => () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
false,
|
|
undefined,
|
|
{ getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) },
|
|
browserControl
|
|
)
|
|
|
|
try {
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: lease.conversationId,
|
|
prompt: 'Continue after the browser tab closes',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() => expect(release).toHaveBeenCalledOnce())
|
|
|
|
expect(grantedLease).toBe(lease)
|
|
expect(lease.signal.aborted).toBe(true)
|
|
expect(runtimeSignal?.aborted).toBe(false)
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
const publicEvents = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, payload]) => payload)
|
|
expect(publicEvents).toContainEqual({ requestId, type: 'done' })
|
|
expect(publicEvents).not.toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ type: 'error' })
|
|
]))
|
|
expect(knowledgeGateway.revoke).toHaveBeenCalledWith('browser-capability')
|
|
} finally {
|
|
await harness.dispose()
|
|
}
|
|
})
|
|
|
|
it.each(['error', 'cancel'] as const)(
|
|
'releases a request-created browser tab lease on runtime %s',
|
|
async (outcome) => {
|
|
const requestId = outcome === 'error'
|
|
? '00000000-0000-4000-8000-000000000029'
|
|
: '00000000-0000-4000-8000-000000000030'
|
|
const tabId = browserTabIdSchema.parse(
|
|
outcome === 'error'
|
|
? '00000000-0000-4000-8000-000000000421'
|
|
: '00000000-0000-4000-8000-000000000422'
|
|
)
|
|
let markStarted!: () => void
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve
|
|
})
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }, signal: AbortSignal) {
|
|
markStarted()
|
|
if (outcome === 'error') throw new Error('runtime failed')
|
|
await new Promise<void>((_resolve, reject) => {
|
|
signal.addEventListener('abort', () => reject(signal.reason), {
|
|
once: true
|
|
})
|
|
})
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const release = vi.fn()
|
|
let grantedLease:
|
|
| { release(): void }
|
|
| undefined
|
|
const knowledgeGateway = {
|
|
grant: vi.fn((...args: unknown[]) => {
|
|
grantedLease = args[7] as { release(): void }
|
|
return 'browser-capability'
|
|
}),
|
|
getAvailableToolNames: vi.fn(() => ['browser_navigate']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn(() => grantedLease?.release())
|
|
}
|
|
const createTab = vi.fn()
|
|
const browserControl = {
|
|
listTabs: vi.fn(() => []),
|
|
getVisibleTabId: vi.fn(() => undefined),
|
|
createTab,
|
|
reserveRequestTab: vi.fn((conversationId: string, owner: string) => ({
|
|
conversationId,
|
|
tabId,
|
|
owner,
|
|
signal: new AbortController().signal,
|
|
release
|
|
})),
|
|
acquireTabUsage: vi.fn(
|
|
(conversationId: string, acquiredTabId: BrowserTabId, owner: string) => ({
|
|
conversationId,
|
|
tabId: acquiredTabId,
|
|
owner,
|
|
signal: new AbortController().signal,
|
|
release
|
|
})
|
|
),
|
|
onState: vi.fn(() => () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
false,
|
|
undefined,
|
|
{ getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) },
|
|
browserControl
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
|
|
await harness.handler?.(event, {
|
|
requestId,
|
|
conversationId: `browser-${outcome}`,
|
|
prompt: '使用浏览器',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await started
|
|
if (outcome === 'cancel') {
|
|
harness.cancelHandler?.(event, requestId)
|
|
}
|
|
await vi.waitFor(() => expect(release).toHaveBeenCalledOnce())
|
|
|
|
expect(createTab).not.toHaveBeenCalled()
|
|
expect(browserControl.acquireTabUsage).not.toHaveBeenCalled()
|
|
expect(browserControl.reserveRequestTab).toHaveBeenCalledWith(
|
|
`browser-${outcome}`,
|
|
requestId,
|
|
harness.webContents.id
|
|
)
|
|
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
|
|
'browser-capability'
|
|
)
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it(
|
|
'does not grant or advertise scoped data tools to external OpenCode',
|
|
async () => {
|
|
let receivedRequest:
|
|
| {
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
}
|
|
| undefined
|
|
const externalOpenCode = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
supportsScopedDataTools: false,
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'must-not-be-granted'),
|
|
getAvailableToolNames: vi.fn(() => ['note_list']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
externalOpenCode,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
true
|
|
)
|
|
const requestId = '00000000-0000-4000-8000-000000000025'
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'external-opencode',
|
|
prompt: '读取笔记',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
|
|
expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined()
|
|
expect(receivedRequest?.trustedInstructions).not.toContain(
|
|
'note_list'
|
|
)
|
|
expect(receivedRequest?.trustedInstructions).not.toContain(
|
|
'Available GoodBuddy data tools:'
|
|
)
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('accepts an authorized knowledge library after the first 100 entries', async () => {
|
|
const libraries = Array.from({ length: 101 }, (_, index) => ({
|
|
id: `00000000-0000-4000-8000-${index
|
|
.toString(16)
|
|
.padStart(12, '0')}`,
|
|
name: `Library ${index}`
|
|
}))
|
|
const listKnowledgeBases = vi.fn(() => libraries)
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'capability'),
|
|
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{ database: { listKnowledgeBases } },
|
|
knowledgeGateway
|
|
)
|
|
const requestId = '00000000-0000-4000-8000-000000000024'
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'later-library',
|
|
prompt: 'search',
|
|
knowledgeLibraryIds: [libraries[100]!.id]
|
|
})
|
|
).resolves.toBeUndefined()
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
expect(listKnowledgeBases).toHaveBeenCalledWith(500)
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
|
requestId,
|
|
[libraries[100]!.id],
|
|
expect.any(AbortSignal),
|
|
'none'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('emits drained knowledge references immediately before done', async () => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
const reference = {
|
|
libraryId,
|
|
libraryName: 'Known',
|
|
documentId: '33333333-3333-4333-8333-333333333333',
|
|
documentName: 'Doc',
|
|
sourceName: 'Source',
|
|
snippet: 'Evidence',
|
|
rank: 1
|
|
}
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
}) {
|
|
expect(request.knowledgeCapabilityToken).toBe('capability')
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'capability'),
|
|
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
|
drainReferences: vi.fn(() => [reference]),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{
|
|
database: {
|
|
listKnowledgeBases: vi.fn(() => [
|
|
{ id: libraryId, name: 'Known' }
|
|
])
|
|
}
|
|
},
|
|
knowledgeGateway
|
|
)
|
|
const requestId = '00000000-0000-4000-8000-000000000023'
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'scoped',
|
|
prompt: 'search',
|
|
knowledgeLibraryIds: [libraryId, libraryId]
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
|
requestId,
|
|
[libraryId],
|
|
expect.any(AbortSignal),
|
|
'none'
|
|
)
|
|
const publicEvents = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, payload]) => payload)
|
|
expect(publicEvents.slice(-2)).toEqual([
|
|
{
|
|
requestId,
|
|
type: 'source-references',
|
|
references: [reference]
|
|
},
|
|
{ requestId, type: 'done' }
|
|
])
|
|
expect(knowledgeGateway.revoke).toHaveBeenCalledWith('capability')
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('coalesces burst deltas while preserving output and terminal order', async () => {
|
|
const deltas = Array.from(
|
|
{ length: 100 },
|
|
(_, index) => `chunk-${index};`
|
|
)
|
|
const expectedOutput = deltas.join('')
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
for (const delta of deltas) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta
|
|
}
|
|
}
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: 'call-burst',
|
|
name: 'read',
|
|
state: 'completed',
|
|
summary: 'read completed'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '00000000-0000-4000-8000-000000000025'
|
|
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'burst-stream',
|
|
prompt: 'stream',
|
|
knowledgeLibraryIds: []
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
const persistedEvents =
|
|
harness.assistantDatabase.appendTaskEvent.mock.calls
|
|
.filter(([taskId]) => taskId === requestId)
|
|
.map(([, , payload]) => payload)
|
|
const publicEvents = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, payload]) => payload)
|
|
.filter((payload) => payload.requestId === requestId)
|
|
expect(persistedEvents).toHaveLength(3)
|
|
expect(publicEvents).toHaveLength(4)
|
|
expect(persistedEvents.map((event) => event.type)).toEqual([
|
|
'text',
|
|
'tool',
|
|
'done'
|
|
])
|
|
expect(publicEvents.map((event) => event.type)).toEqual([
|
|
'text',
|
|
'text',
|
|
'tool',
|
|
'done'
|
|
])
|
|
expect(publicEvents[0]).toEqual({
|
|
requestId,
|
|
type: 'text',
|
|
delta: deltas[0]
|
|
})
|
|
expect(persistedEvents[0]).toEqual({
|
|
requestId,
|
|
type: 'text',
|
|
delta: expectedOutput
|
|
})
|
|
expect(
|
|
publicEvents
|
|
.filter(
|
|
(
|
|
event
|
|
): event is Extract<AgentEvent, { type: 'text' }> =>
|
|
event.type === 'text'
|
|
)
|
|
.map((event) => event.delta)
|
|
.join('')
|
|
).toBe(expectedOutput)
|
|
expect(
|
|
harness.assistantDatabase.createTextArtifact
|
|
).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each([false, true])('preflights always-retrieve mode with external=%s and injects bounded untrusted evidence', async (external) => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
const documentId = '33333333-3333-4333-8333-333333333333'
|
|
const chunkId = '44444444-4444-4444-8444-444444444444'
|
|
const run = vi.fn(async function* (request: {
|
|
requestId: string
|
|
prompt: string
|
|
trustedInstructions?: string
|
|
}) {
|
|
expect(request.prompt).toContain(
|
|
'BEGIN_UNTRUSTED_KNOWLEDGE_EVIDENCE'
|
|
)
|
|
expect(request.prompt).toContain('离线部署需要先校验安装包')
|
|
expect(request.prompt).toContain('ORIGINAL_USER_REQUEST')
|
|
expect(request.prompt).not.toContain('C:\\private')
|
|
expect(request.trustedInstructions).toContain(
|
|
'untrusted quoted data'
|
|
)
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
})
|
|
const retrievalResponse = {
|
|
query: '如何离线部署?',
|
|
durationMs: 12,
|
|
settings: {
|
|
version: 1 as const,
|
|
topK: 6,
|
|
minimumVectorSimilarity: 0,
|
|
ftsWeight: 1,
|
|
vectorWeight: 1,
|
|
graphWeight: 0.8,
|
|
candidateMultiplier: 4,
|
|
contextMaxCharacters: 16_000,
|
|
adjacentChunkCount: 0,
|
|
localRerankEnabled: false
|
|
},
|
|
diagnostics: {
|
|
requestedChannels: ['fts' as const],
|
|
usedChannels: ['fts' as const],
|
|
degradedChannels: [],
|
|
candidateCounts: { fts: 1 },
|
|
channelDurationMs: { fts: 4 },
|
|
vectorScannedCount: 0,
|
|
filteredByThresholdCount: 0,
|
|
filteredByBudgetCount: 0,
|
|
rerank: {
|
|
requested: 'none' as const,
|
|
used: 'none' as const,
|
|
status: 'skipped' as const,
|
|
candidateCount: 1,
|
|
durationMs: 0
|
|
}
|
|
},
|
|
results: [
|
|
{
|
|
knowledgeBaseId: libraryId,
|
|
documentId: external ? undefined : documentId,
|
|
sourceId: external ? undefined : '55555555-5555-4555-8555-555555555555',
|
|
chunkId: external ? undefined : chunkId,
|
|
external: external ? { kind: 'external' as const, provider: 'dify' as const, instanceId: libraryId, remoteKnowledgeBaseId: 'remote-kb', remoteChunkId: 'remote-chunk', location: 'Remote section 3' } : undefined,
|
|
documentTitle: '离线部署.md',
|
|
sourceDisplayName: '产品手册',
|
|
sourceType: 'file' as const,
|
|
location: '第 2 节',
|
|
snippet: '离线部署需要先校验安装包',
|
|
relevance: external ? undefined : 0.9,
|
|
rank: 1,
|
|
channels: ['fts' as const],
|
|
scores: {
|
|
ftsRank: 1,
|
|
fusedScore: 0.8
|
|
}
|
|
}
|
|
],
|
|
context: {
|
|
characterCount: 13,
|
|
truncated: false,
|
|
groups: [
|
|
{
|
|
resultChunkId: chunkId,
|
|
chunkIds: [chunkId],
|
|
documentId,
|
|
content: '离线部署需要先校验安装包',
|
|
characterCount: 13,
|
|
truncated: false
|
|
}
|
|
]
|
|
}
|
|
}
|
|
if (external) {
|
|
retrievalResponse.context.groups = []
|
|
retrievalResponse.results.push({ ...retrievalResponse.results[0]!, rank: 2, external: { ...retrievalResponse.results[0]!.external!, remoteChunkId: 'second-chunk' } })
|
|
}
|
|
const retrieveMany = vi.fn(async () => [
|
|
{ knowledgeBaseId: libraryId, response: retrievalResponse }
|
|
])
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'capability'),
|
|
getAvailableToolNames: vi.fn(() => ['knowledge_search']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
{
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
supportsScopedDataTools: !external,
|
|
run
|
|
},
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{
|
|
database: {
|
|
listKnowledgeBases: vi.fn(() => [
|
|
{ id: libraryId, name: '产品知识' }
|
|
])
|
|
},
|
|
retrieveMany
|
|
},
|
|
knowledgeGateway
|
|
)
|
|
const requestId = '00000000-0000-4000-8000-000000000024'
|
|
await harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'always-retrieve',
|
|
prompt: '如何离线部署?',
|
|
knowledgeLibraryIds: [libraryId],
|
|
knowledgeRetrievalMode: 'always'
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
|
|
expect(retrieveMany).toHaveBeenCalledWith(
|
|
[libraryId],
|
|
'如何离线部署?',
|
|
expect.any(AbortSignal)
|
|
)
|
|
const publicEvents = harness.webContents.send.mock.calls
|
|
.filter(([channel]) => channel === ipcChannels.agentEvent)
|
|
.map(([, payload]) => payload)
|
|
expect(publicEvents).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: 'knowledge-retrieval',
|
|
state: 'searching'
|
|
}),
|
|
expect.objectContaining({
|
|
type: 'knowledge-retrieval',
|
|
state: 'succeeded',
|
|
resultCount: external ? 2 : 1
|
|
}),
|
|
expect.objectContaining({
|
|
type: 'source-references',
|
|
references: external ? expect.arrayContaining([
|
|
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'remote-chunk' }) }),
|
|
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second-chunk' }) })
|
|
]) : [expect.objectContaining({ chunkId, documentId })]
|
|
})
|
|
])
|
|
)
|
|
expect(run).toHaveBeenCalledOnce()
|
|
if (external) expect(run.mock.calls[0]![0].prompt).toContain('"locator":"Remote section 3"')
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('cancels an active full-library rebuild through its scoped controller', async () => {
|
|
const libraryId = '11111111-1111-4111-8111-111111111111'
|
|
let resolveRebuild:
|
|
| ((value: { rebuilt: number; failed: number }) => void)
|
|
| undefined
|
|
const rebuildLibrary = vi.fn(
|
|
() =>
|
|
new Promise<{ rebuilt: number; failed: number }>(
|
|
(resolve) => {
|
|
resolveRebuild = resolve
|
|
}
|
|
)
|
|
)
|
|
const cancelLibraryRebuild = vi.fn(() => true)
|
|
const harness = createHarness(
|
|
{
|
|
capability: 'chat',
|
|
supportsToolExecution: true
|
|
},
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{
|
|
database: { listKnowledgeBases: vi.fn(() => []) },
|
|
cancelLibraryRebuild,
|
|
rebuildLibrary
|
|
}
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
const rebuild = harness.knowledgeRebuildHandler?.(event, {
|
|
knowledgeBaseId: libraryId
|
|
}) as Promise<unknown>
|
|
await vi.waitFor(() => expect(rebuildLibrary).toHaveBeenCalledOnce())
|
|
|
|
expect(
|
|
await Promise.resolve(
|
|
harness.knowledgeCancelRebuildHandler?.(event, libraryId)
|
|
)
|
|
).toBe(true)
|
|
expect(cancelLibraryRebuild).toHaveBeenCalledWith(libraryId)
|
|
resolveRebuild?.({ rebuilt: 0, failed: 0 })
|
|
await expect(rebuild).resolves.toEqual({ rebuilt: 0, failed: 0 })
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('returns no results for an explicitly empty knowledge search scope', async () => {
|
|
const retrieveMany = vi.fn(() => {
|
|
throw new Error('must not search')
|
|
})
|
|
const harness = createHarness(
|
|
{
|
|
capability: 'chat',
|
|
supportsToolExecution: true
|
|
},
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{
|
|
database: { listKnowledgeBases: vi.fn(() => []) },
|
|
retrieveMany
|
|
}
|
|
)
|
|
await expect(
|
|
harness.knowledgeSearchHandler?.(
|
|
trustedEvent(harness.webContents),
|
|
{ libraryIds: [], query: 'anything' }
|
|
)
|
|
).resolves.toEqual([])
|
|
expect(retrieveMany).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('routes external UI payloads through IPC, the production service and citation search', async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-external-ipc-'))
|
|
const fetcher = vi.fn<typeof fetch>(async (_url, init) => Response.json(init?.method === 'POST'
|
|
? { records: [{ score: 0.75, segment: { id: 'remote-chunk', content: 'Policy evidence', document: { id: 'remote-document', name: 'Policy' } } }] }
|
|
: { data: [{ id: 'dataset', name: 'Policy' }], has_more: false }))
|
|
const service = new TestKnowledgeService({
|
|
databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'),
|
|
externalFetcher: fetcher,
|
|
credentialCipher: { isAvailable: () => true, encrypt: value => Buffer.from(value), decrypt: value => value.toString() }
|
|
})
|
|
await service.initialize()
|
|
const harness = createHarness({ capability: 'chat' }, undefined, undefined, false, undefined, service as unknown as Record<string, unknown>)
|
|
const event = trustedEvent(harness.webContents)
|
|
const invoke = (channel: string, input?: unknown) => electronMocks.handlers.get(channel)!(event, input)
|
|
try {
|
|
const instance = await invoke(ipcChannels.externalInstancesSave, {
|
|
name: 'External instance', provider: 'dify', baseUrl: 'https://kb.example', enabled: true,
|
|
credential: { action: 'replace', value: 'test-key' }
|
|
}) as { id: string }
|
|
expect(instance).not.toHaveProperty('credential')
|
|
await expect(invoke(ipcChannels.externalCatalogList, { instanceId: instance.id })).resolves.toMatchObject({ items: [{ id: 'dataset' }] })
|
|
await expect(invoke(ipcChannels.externalBindingsCreate, { instanceId: instance.id })).rejects.toThrow()
|
|
const snapshot = await invoke(ipcChannels.externalBindingsCreate, {
|
|
instanceId: instance.id, remoteKnowledgeBaseId: 'dataset', name: 'Policy', remoteName: 'Policy',
|
|
testQuery: 'policy', commonConfig: { resultLimit: 6, requestTimeoutMs: 1000, maxSnippetCharacters: 4000 },
|
|
providerConfig: { provider: 'dify', useDatasetDefaults: true }
|
|
}) as { libraries: Array<{ id: string; kind: string }> }
|
|
expect(snapshot.libraries[0]?.kind).toBe('external')
|
|
const libraryId = snapshot.libraries[0]!.id
|
|
await expect(invoke(ipcChannels.knowledgeRetrieve, { knowledgeBaseId: libraryId, query: 'policy' })).resolves.toMatchObject({
|
|
results: [{ external: { remoteChunkId: 'remote-chunk' }, snippet: 'Policy evidence' }]
|
|
})
|
|
await expect(invoke(ipcChannels.knowledgeSearch, { libraryIds: [libraryId], query: 'policy' })).resolves.toMatchObject([
|
|
{ libraryId, documentId: undefined, snippet: 'Policy evidence', external: { remoteDocumentId: 'remote-document', remoteChunkId: 'remote-chunk', providerScore: 0.75 } }
|
|
])
|
|
await invoke(ipcChannels.externalInstancesSetEnabled, { instanceId: instance.id, enabled: false })
|
|
fetcher.mockClear()
|
|
await expect(invoke(ipcChannels.knowledgeSearch, { libraryIds: [libraryId], query: 'policy' })).rejects.toThrow('EXTERNAL_KB_DISABLED')
|
|
expect(fetcher).not.toHaveBeenCalled()
|
|
} finally {
|
|
await harness.dispose()
|
|
await service.dispose()
|
|
await rm(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('routes status and concurrent conversations to their selected runtimes', async () => {
|
|
const firstProfileId = '00000000-0000-4000-8000-000000000001'
|
|
const secondProfileId = '00000000-0000-4000-8000-000000000002'
|
|
const firstSelection = {
|
|
provider: 'model' as const,
|
|
profileId: firstProfileId
|
|
}
|
|
const secondSelection = {
|
|
provider: 'model' as const,
|
|
profileId: secondProfileId
|
|
}
|
|
const firstRun = vi.fn()
|
|
const secondRun = vi.fn()
|
|
const createRuntime = (
|
|
label: string,
|
|
run: typeof firstRun
|
|
): Record<string, unknown> => ({
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(async () => ({
|
|
id: 'model',
|
|
label,
|
|
available: true,
|
|
supportsToolExecution: true
|
|
})),
|
|
dispose: vi.fn(async () => undefined),
|
|
async *run(request: { requestId: string; conversationId: string }) {
|
|
run(request)
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
})
|
|
const firstRuntime = createRuntime('model-one', firstRun)
|
|
const secondRuntime = createRuntime('model-two', secondRun)
|
|
const selectedRuntimes = {
|
|
getStatus: vi.fn(async () => ({
|
|
id: 'model',
|
|
label: 'model-two',
|
|
available: true,
|
|
supportsToolExecution: true
|
|
})),
|
|
getRuntime: vi.fn(async (selection: typeof firstSelection) =>
|
|
selection.profileId === firstProfileId
|
|
? firstRuntime
|
|
: secondRuntime
|
|
),
|
|
releaseConversation: vi.fn(async () => undefined)
|
|
}
|
|
const fallbackRuntime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(async () => ({
|
|
id: 'model',
|
|
label: 'fallback',
|
|
available: true,
|
|
supportsToolExecution: true
|
|
})),
|
|
run: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
fallbackRuntime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
|
|
await expect(
|
|
harness.statusHandler?.(event, secondSelection)
|
|
).resolves.toEqual(
|
|
expect.objectContaining({ label: 'model-two' })
|
|
)
|
|
expect(selectedRuntimes.getStatus).toHaveBeenCalledWith(
|
|
secondSelection
|
|
)
|
|
harness.getResolvedSettings.mockResolvedValueOnce({
|
|
provider: 'opencode',
|
|
defaultModelProfileId: firstProfileId,
|
|
opencodeBaseUrl: '',
|
|
opencodeEmbedded: true,
|
|
opencodeModelSource: { kind: 'platform' },
|
|
continueModelSource: { kind: 'platform' }
|
|
})
|
|
await expect(
|
|
harness.statusHandler?.(event)
|
|
).resolves.toEqual(
|
|
expect.objectContaining({ label: 'model-two' })
|
|
)
|
|
// Resolved selections name the model source explicitly.
|
|
expect(selectedRuntimes.getStatus).toHaveBeenLastCalledWith({
|
|
provider: 'opencode',
|
|
runtimeConfig: true
|
|
})
|
|
expect(fallbackRuntime.getStatus).not.toHaveBeenCalled()
|
|
|
|
await Promise.all([
|
|
harness.handler?.(event, {
|
|
requestId: '00000000-0000-4000-8000-000000000011',
|
|
conversationId: 'conversation-one',
|
|
projectId: '00000000-0000-4000-8000-000000000101',
|
|
prompt: 'first request',
|
|
runtimeSelection: firstSelection
|
|
}),
|
|
harness.handler?.(event, {
|
|
requestId: '00000000-0000-4000-8000-000000000012',
|
|
conversationId: 'conversation-two',
|
|
prompt: 'second request',
|
|
runtimeSelection: secondSelection
|
|
})
|
|
])
|
|
|
|
await vi.waitFor(() => {
|
|
expect(firstRun).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
conversationId: 'conversation-one',
|
|
runtimeSelection: firstSelection
|
|
})
|
|
)
|
|
expect(secondRun).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
conversationId: 'conversation-two',
|
|
runtimeSelection: secondSelection
|
|
})
|
|
)
|
|
})
|
|
expect(fallbackRuntime.run).not.toHaveBeenCalled()
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
|
|
firstSelection,
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
})
|
|
)
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
|
|
secondSelection,
|
|
undefined
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('resolves the latest project rule for ordinary runs and lets explicit selections override it', async () => {
|
|
const requestRuntime = {
|
|
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => requestRuntime),
|
|
releaseConversation: vi.fn(async () => undefined)
|
|
}
|
|
const harness = createHarness(requestRuntime, undefined, undefined, false, selectedRuntimes)
|
|
const projectId = '00000000-0000-4000-8000-000000000101'
|
|
const fixedProfileId = '00000000-0000-4000-8000-000000000042'
|
|
const project = { id: projectId, rootPath: 'C:\\ProjectWorkspace' }
|
|
harness.getResolvedSettings.mockResolvedValue({
|
|
provider: 'model', defaultModelProfileId: fixedProfileId, opencodeBaseUrl: '',
|
|
modelProfiles: [{
|
|
id: fixedProfileId, name: 'Fixed', modelName: 'fixed', baseUrl: 'https://models.example/v1',
|
|
protocol: 'openai-chat-completions', authentication: 'none'
|
|
}]
|
|
})
|
|
harness.assistantDatabase.getProject.mockReturnValue({ ...project, runtimeSelection: { provider: 'opencode' } })
|
|
const event = trustedEvent(harness.webContents)
|
|
await harness.handler?.(event, {
|
|
projectId, conversationId: 'project-rule-one',
|
|
requestId: '00000000-0000-4000-8000-000000000011', prompt: 'one'
|
|
})
|
|
// No project model: OpenCode uses its global default (own configuration here).
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith(
|
|
{ provider: 'opencode', runtimeConfig: true }, expect.anything()
|
|
)
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
...project,
|
|
runtimeSelection: { provider: 'continue', model: { kind: 'profile', profileId: fixedProfileId } }
|
|
})
|
|
await harness.handler?.(event, {
|
|
projectId, conversationId: 'project-rule-two',
|
|
requestId: '00000000-0000-4000-8000-000000000012', prompt: 'two'
|
|
})
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith(
|
|
{ provider: 'continue', profileId: fixedProfileId }, expect.anything()
|
|
)
|
|
await harness.handler?.(event, {
|
|
projectId, conversationId: 'project-rule-three', runtimeSelection: { provider: 'model' },
|
|
requestId: '00000000-0000-4000-8000-000000000013', prompt: 'three'
|
|
})
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith({ provider: 'model' }, expect.anything())
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('rejects an active duplicate before resolving Runtime or settings again', async () => {
|
|
let releaseRun!: () => void
|
|
const runReleased = new Promise<void>((resolve) => {
|
|
releaseRun = resolve
|
|
})
|
|
let markRunStarted!: () => void
|
|
const runStarted = new Promise<void>((resolve) => {
|
|
markRunStarted = resolve
|
|
})
|
|
const selectedRuntime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
markRunStarted()
|
|
await runReleased
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => selectedRuntime),
|
|
getStatus: vi.fn(),
|
|
releaseConversation: vi.fn(async () => undefined)
|
|
}
|
|
const getWebSearchCapabilityStatus = vi.fn(async () => ({
|
|
enabled: true
|
|
}))
|
|
const getEnabledBuiltinMcpServerIds = vi.fn(async () => [])
|
|
const harness = createHarness(
|
|
selectedRuntime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
selectedRuntimes,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
{
|
|
getWebSearchCapabilityStatus,
|
|
getEnabledBuiltinMcpServerIds
|
|
}
|
|
)
|
|
const event = trustedEvent(harness.webContents)
|
|
harness.getApplicationSettings.mockClear()
|
|
const request = {
|
|
requestId: '00000000-0000-4000-8000-000000000013',
|
|
conversationId: 'duplicate-request',
|
|
projectId: '00000000-0000-4000-8000-000000000101',
|
|
prompt: 'run once'
|
|
}
|
|
|
|
await harness.handler?.(event, request)
|
|
await runStarted
|
|
await expect(harness.handler?.(event, request)).rejects.toThrow(
|
|
'请求正在执行'
|
|
)
|
|
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce()
|
|
expect(harness.getApplicationSettings).toHaveBeenCalledOnce()
|
|
expect(getWebSearchCapabilityStatus).toHaveBeenCalledOnce()
|
|
expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledOnce()
|
|
expect(harness.contextManager.enrichRequest).toHaveBeenCalledOnce()
|
|
|
|
releaseRun()
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
request.requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('starts independent interactive request lookups concurrently after enrichment', async () => {
|
|
let resolveApplicationSettings!: (value: {
|
|
magicNotesEnabled: boolean
|
|
}) => void
|
|
const applicationSettings = new Promise<{
|
|
magicNotesEnabled: boolean
|
|
}>((resolve) => {
|
|
resolveApplicationSettings = resolve
|
|
})
|
|
let resolveWebSearch!: (value: { enabled: boolean }) => void
|
|
const webSearch = new Promise<{ enabled: boolean }>((resolve) => {
|
|
resolveWebSearch = resolve
|
|
})
|
|
let resolveRuntimeSettings!: (value: {
|
|
workspacePath: string
|
|
}) => void
|
|
const runtimeSettings = new Promise<{
|
|
workspacePath: string
|
|
}>((resolve) => {
|
|
resolveRuntimeSettings = resolve
|
|
})
|
|
let resolveBuiltinMcpServers!: (value: never[]) => void
|
|
const builtinMcpServers = new Promise<never[]>((resolve) => {
|
|
resolveBuiltinMcpServers = resolve
|
|
})
|
|
const getWebSearchCapabilityStatus = vi.fn(() => webSearch)
|
|
const getEnabledBuiltinMcpServerIds = vi.fn(
|
|
() => builtinMcpServers
|
|
)
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const goodbuddyConfigService = {
|
|
takePendingReload: vi.fn(() => 'none'),
|
|
revokeRequest: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
goodbuddyConfigService,
|
|
{
|
|
getWebSearchCapabilityStatus,
|
|
getEnabledBuiltinMcpServerIds
|
|
}
|
|
)
|
|
harness.getApplicationSettings.mockClear()
|
|
harness.getApplicationSettings.mockReturnValueOnce(
|
|
applicationSettings
|
|
)
|
|
harness.getResolvedSettings.mockReturnValueOnce(runtimeSettings)
|
|
const requestId = '00000000-0000-4000-8000-000000000014'
|
|
const operation = harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
{
|
|
requestId,
|
|
conversationId: 'concurrent-request-setup',
|
|
prompt: 'prepare concurrently',
|
|
knowledgeLibraryIds: []
|
|
}
|
|
)
|
|
|
|
await vi.waitFor(() => {
|
|
expect(harness.getApplicationSettings).toHaveBeenCalledOnce()
|
|
expect(getWebSearchCapabilityStatus).toHaveBeenCalledOnce()
|
|
expect(harness.getResolvedSettings).toHaveBeenCalledOnce()
|
|
expect(getEnabledBuiltinMcpServerIds).toHaveBeenCalledOnce()
|
|
})
|
|
const enrichmentOrder =
|
|
harness.contextManager.enrichRequest.mock.invocationCallOrder[0]
|
|
if (enrichmentOrder === undefined) {
|
|
throw new Error('Request was not enriched')
|
|
}
|
|
for (const lookup of [
|
|
harness.getApplicationSettings,
|
|
getWebSearchCapabilityStatus,
|
|
harness.getResolvedSettings,
|
|
getEnabledBuiltinMcpServerIds
|
|
]) {
|
|
const lookupOrder = lookup.mock.invocationCallOrder[0]
|
|
if (lookupOrder === undefined) {
|
|
throw new Error('Interactive request lookup did not start')
|
|
}
|
|
expect(enrichmentOrder).toBeLessThan(lookupOrder)
|
|
}
|
|
|
|
resolveApplicationSettings({ magicNotesEnabled: false })
|
|
resolveWebSearch({ enabled: false })
|
|
resolveRuntimeSettings({ workspacePath: 'C:\\Workspace' })
|
|
resolveBuiltinMcpServers([])
|
|
await expect(operation).resolves.toBeUndefined()
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('aborts active work and clears browser sessions before assistant data', async () => {
|
|
const lifecycle: string[] = []
|
|
let markStarted!: () => void
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve
|
|
})
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(
|
|
_request: unknown,
|
|
signal: AbortSignal
|
|
): AsyncGenerator<never, void, void> {
|
|
markStarted()
|
|
await new Promise<void>((_resolve, reject) => {
|
|
signal.addEventListener(
|
|
'abort',
|
|
() => {
|
|
lifecycle.push('aborted')
|
|
reject(signal.reason)
|
|
},
|
|
{ once: true }
|
|
)
|
|
})
|
|
yield undefined as never
|
|
}
|
|
}
|
|
const harness = createHarness(runtime, async () => {
|
|
lifecycle.push('browser-cleared')
|
|
})
|
|
vi.mocked(
|
|
harness.assistantDatabase.clearAssistantData
|
|
).mockImplementation(() => {
|
|
lifecycle.push('assistant-cleared')
|
|
})
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-clear',
|
|
prompt: 'keep running'
|
|
})
|
|
await started
|
|
|
|
await harness.clearHandler?.(trustedEvent(harness.webContents))
|
|
|
|
expect(lifecycle).toEqual([
|
|
'aborted',
|
|
'browser-cleared',
|
|
'assistant-cleared'
|
|
])
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('coalesces concurrent local-data clear requests', async () => {
|
|
let releaseClear!: () => void
|
|
const clearBlocked = new Promise<void>((resolve) => {
|
|
releaseClear = resolve
|
|
})
|
|
const onBeforeClearLocalData = vi.fn(async () => {
|
|
await clearBlocked
|
|
})
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn()
|
|
}
|
|
const harness = createHarness(runtime, onBeforeClearLocalData)
|
|
const event = trustedEvent(harness.webContents)
|
|
|
|
const firstClear = harness.clearHandler?.(event)
|
|
const secondClear = harness.clearHandler?.(event)
|
|
|
|
expect(firstClear).toBe(secondClear)
|
|
await vi.waitFor(() => {
|
|
expect(onBeforeClearLocalData).toHaveBeenCalledOnce()
|
|
})
|
|
await expect(
|
|
harness.handler?.(event, {
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-during-clear',
|
|
prompt: 'do not start'
|
|
})
|
|
).rejects.toThrow('本地数据维护期间暂不接受新任务')
|
|
|
|
releaseClear()
|
|
await expect(firstClear).resolves.toBeUndefined()
|
|
expect(
|
|
harness.assistantDatabase.clearAssistantData
|
|
).toHaveBeenCalledOnce()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('marks a request failed when a tool fails before runtime done', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: 'call-1',
|
|
name: 'write',
|
|
state: 'failed',
|
|
summary: 'OpenCode 工具:write',
|
|
error: 'write path denied'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'write a file'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
'write 工具执行失败:write path denied'
|
|
)
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(requestId, 'completed')
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({
|
|
requestId,
|
|
type: 'error',
|
|
status: 'failed',
|
|
message: 'write 工具执行失败:write path denied'
|
|
})
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('allows a completed request after a recoverable tool failure', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: 'call-recoverable',
|
|
name: '浏览器输入',
|
|
state: 'recoverable',
|
|
summary: '直连模型工具需要刷新后重试:浏览器输入'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'retry browser input'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
expect.any(String)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('does not fail a completed native subagent for its provisional Task tool', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: 'call-task-1',
|
|
name: 'task',
|
|
state: 'running',
|
|
summary: 'OpenCode 工具:task'
|
|
}
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'subagent',
|
|
childTaskId: '00000000-0000-4000-8000-000000000601',
|
|
expertId: '00000000-0000-4000-8000-000000000701',
|
|
expertName: 'explore',
|
|
routingMode: 'native',
|
|
runtimeCallId: 'call-task-1',
|
|
state: 'completed',
|
|
output: 'Desktop files inspected.'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'inspect the desktop'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
expect.any(String)
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each(['opencode', 'continue'] as const)(
|
|
'dispatches ordinary requests without an authorizer at the %s Runtime boundary',
|
|
async (runtimeId) => {
|
|
let received:
|
|
| {
|
|
request: { requestId: string }
|
|
authorize: unknown
|
|
}
|
|
| undefined
|
|
const runtime = {
|
|
runtimeId,
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(
|
|
request: { requestId: string },
|
|
_signal: AbortSignal,
|
|
authorize: unknown
|
|
) {
|
|
received = { request, authorize }
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'run the task'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
expect(received?.request).not.toHaveProperty('workMode')
|
|
expect(received?.authorize).toBeUndefined()
|
|
expect(
|
|
harness.assistantDatabase.createTask
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: requestId })
|
|
)
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('runs DeepSeek Harness tools without a general authorizer', async () => {
|
|
const receivedAuthorizers: unknown[] = []
|
|
const runtime = {
|
|
runtimeId: 'deepseek-harness',
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(
|
|
request: { requestId: string },
|
|
_signal: AbortSignal,
|
|
authorize?: (request: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}) => Promise<string>
|
|
) {
|
|
receivedAuthorizers.push(authorize)
|
|
yield { requestId: request.requestId, type: 'tool', callId: 'write-file',
|
|
name: 'write_file', state: 'completed', summary: 'Wrote file' }
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
|
|
for (const index of [0, 1]) {
|
|
const requestId = `3f496642-f47d-4e0a-8944-a32c77b0d6e${index}`
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: `conversation-${index}`,
|
|
prompt: 'run the task'
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
}
|
|
|
|
expect(receivedAuthorizers).toEqual([undefined, undefined])
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each(['model', 'opencode'] as const)(
|
|
'uses capability instructions without mode metadata for %s',
|
|
async (runtimeId) => {
|
|
let receivedRequest:
|
|
| { requestId: string; prompt: string }
|
|
| undefined
|
|
const runtime = {
|
|
runtimeId,
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: {
|
|
requestId: string
|
|
prompt: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'draft a plan'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
expect(receivedRequest).not.toHaveProperty('workMode')
|
|
expect(receivedRequest?.prompt).toContain('enabled capabilities')
|
|
expect(receivedRequest?.prompt).not.toContain('Work mode:')
|
|
expect(
|
|
harness.assistantDatabase.createTask
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: requestId })
|
|
)
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('sends capability instructions only once to runtimes that consume trusted instructions', async () => {
|
|
let receivedRequest:
|
|
| { requestId: string; prompt: string; trustedInstructions?: string }
|
|
| undefined
|
|
const runtime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
consumesTrustedInstructions: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: {
|
|
requestId: string
|
|
prompt: string
|
|
trustedInstructions?: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '5c3e1f7a-2b8d-4e6f-9a1c-7d2b4e6f8a10'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'fix the bug'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
expect(receivedRequest?.prompt).toBe('fix the bug')
|
|
expect(receivedRequest?.trustedInstructions).toContain('enabled capabilities')
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each([
|
|
{ route: 'manual', consumesTrustedInstructions: true },
|
|
{ route: 'smart', consumesTrustedInstructions: true },
|
|
{ route: 'manual', consumesTrustedInstructions: false }
|
|
] as const)('applies a $route expert as a role prompt on the conversation runtime (system channel: $consumesTrustedInstructions)', async ({ route, consumesTrustedInstructions }) => {
|
|
const received: Array<{ requestId: string; prompt: string; trustedInstructions?: string }> = []
|
|
const runtime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
consumesTrustedInstructions,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: { requestId: string; prompt: string; trustedInstructions?: string }) {
|
|
received.push(request)
|
|
yield { requestId: request.requestId, type: 'text', delta: '角色回答' }
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const expert = {
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
name: '研究专家',
|
|
description: '',
|
|
systemInstructions: 'Analyze evidence.',
|
|
routingKeywords: ['资料分析'],
|
|
enabled: true,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z'
|
|
}
|
|
const subagentService = {
|
|
run: vi.fn(),
|
|
cancelAll: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const harness = createHarness(runtime, undefined, subagentService, true)
|
|
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([expert])
|
|
vi.mocked(harness.assistantDatabase.getExpert).mockReturnValue(expert)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-role',
|
|
prompt: '请做资料分析',
|
|
...(route === 'manual' ? { expertId: expert.id } : { smartRouting: true })
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
expect(received).toHaveLength(1)
|
|
const request = received[0]!
|
|
expect(request.trustedInstructions).toContain('enabled capabilities')
|
|
expect(request.trustedInstructions).toContain('"研究专家"')
|
|
expect(request.trustedInstructions).toContain('Analyze evidence.')
|
|
if (consumesTrustedInstructions) {
|
|
expect(request.prompt).toBe('请做资料分析')
|
|
} else {
|
|
expect(request.prompt).toMatch(/enabled capabilities[\s\S]*Analyze evidence\.\n\n请做资料分析$/u)
|
|
}
|
|
expect(subagentService.run).not.toHaveBeenCalled()
|
|
expect(harness.assistantDatabase.appendTaskEvent).not.toHaveBeenCalledWith(
|
|
requestId,
|
|
'subagent',
|
|
expect.anything()
|
|
)
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({ type: 'text', delta: '角色回答' })
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('binds expert team writes to each request project instead of the global workspace', async () => {
|
|
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
|
|
const directory = await mkdtemp(join(tmpdir(), 'expert-projects-'))
|
|
const globalRoot = join(directory, 'global')
|
|
const roots = [join(directory, 'alpha'), join(directory, 'beta')]
|
|
await Promise.all([globalRoot, ...roots].map(root => mkdir(root)))
|
|
const settings = { ...runtimeUpdateFixture(globalRoot).publicSettings,
|
|
provider: 'model' as const, modelAuthentication: 'none' as const, apiKey: 'test-only',
|
|
runtimeCustomization: defaultRuntimeSettings.runtimeCustomization }
|
|
const projectIds: string[] = [crypto.randomUUID(), crypto.randomUUID()]
|
|
let toolCall = 0
|
|
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
|
|
const body = JSON.parse(String(init?.body))
|
|
const messages = body.messages as Array<{ role: string; content: string }>
|
|
if (!body.tools) return new Response('data: {"choices":[{"delta":{"content":"Combined answer"},"finish_reason":null}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
|
|
{ headers: { 'content-type': 'text/event-stream' } })
|
|
if (body.tools && !messages.some(message => message.role === 'tool')) {
|
|
const label = messages.some(message => message.role === 'user' && message.content.includes('alpha')) ? 'alpha' : 'beta'
|
|
const id = `write-${++toolCall}`
|
|
expect(body.tools).toContainEqual(expect.objectContaining({ function: expect.objectContaining({ name: 'workspace_apply_patch' }) }))
|
|
return Response.json({ choices: [{ message: { role: 'assistant', content: null,
|
|
tool_calls: [{ id, type: 'function', function: { name: 'workspace_apply_patch',
|
|
arguments: JSON.stringify({ patch: `*** Begin Patch\n*** Add File: ${id}.txt\n+${label}\n*** End Patch` }) } }] }, finish_reason: 'tool_calls' }] })
|
|
}
|
|
return Response.json({ choices: [{ message: { role: 'assistant', content: 'Written' }, finish_reason: 'stop' }] })
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
runtimeFactoryMocks.createDefaultModelRuntime.mockImplementation(actual.createDefaultModelRuntime)
|
|
const expert = { id: crypto.randomUUID(), name: 'Writer', description: '', systemInstructions: 'Write the requested file.',
|
|
routingKeywords: ['write'], enabled: true, createdAt: '', updatedAt: '' }
|
|
const requests: Parameters<SubagentService['run']>[0][] = []
|
|
const db = { createTask: vi.fn(), updateTaskStatus: vi.fn(), appendTaskEvent: vi.fn() }
|
|
const service = new SubagentService(async input => {
|
|
requests.push(input)
|
|
return createSubagentRuntime(input, settings,
|
|
async (selectedSettings, executionSpace) => actual.createAgentRuntime(globalRoot, selectedSettings, { executionSpace }),
|
|
async () => { throw new Error('Local experts must not select a remote runtime') })
|
|
}, db as never)
|
|
const ordinary = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run: vi.fn() }
|
|
const getRuntime = vi.fn<SelectedRuntimeResolver['getRuntime']>(async () => ordinary as never)
|
|
const harness = createHarness(ordinary, undefined, service as unknown as Record<string, unknown>, true, { getRuntime })
|
|
harness.getResolvedSettings.mockResolvedValue(settings)
|
|
harness.assistantDatabase.getProject.mockImplementation(id => ({ id, rootPath: roots[projectIds.indexOf(id)], runtimeSelection: { provider: 'model' } }))
|
|
harness.assistantDatabase.getExpert.mockReturnValue(expert)
|
|
harness.assistantDatabase.listExperts.mockReturnValue([expert, { ...expert, id: crypto.randomUUID(), name: 'Second', routingKeywords: [] }])
|
|
try {
|
|
for (const [index, projectId] of projectIds.entries()) {
|
|
const requestId = crypto.randomUUID()
|
|
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: `project-${index}`, projectId,
|
|
prompt: `write ${index === 0 ? 'alpha' : 'beta'}`, teamMode: true })
|
|
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
|
|
const files = await readdir(roots[index]!)
|
|
expect(files).toHaveLength(2)
|
|
for (const file of files) expect(await readFile(join(roots[index]!, file), 'utf8')).toBe(index === 0 ? 'alpha' : 'beta')
|
|
for (const input of requests.filter(input => input.parentRequest.requestId === requestId)) {
|
|
expect(input.executionSpace).toBe(getRuntime.mock.calls[index]?.[1])
|
|
expect(input.executionSpace).toMatchObject({ kind: 'local', rootPath: roots[index] })
|
|
}
|
|
}
|
|
expect(await readdir(globalRoot)).toEqual([])
|
|
expect(ordinary.run).not.toHaveBeenCalled()
|
|
} finally {
|
|
await harness.dispose()
|
|
vi.unstubAllGlobals()
|
|
runtimeFactoryMocks.createDefaultModelRuntime.mockReset()
|
|
await rm(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it.each(['opencode', 'continue'] as const)('uses managed %s for SSH expert team members with their profile and parent bindings', async (provider) => {
|
|
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
|
|
const globalRoot = await mkdtemp(join(tmpdir(), 'expert-ssh-'))
|
|
const projectId = crypto.randomUUID()
|
|
const remoteAccess = {
|
|
getIdentity: vi.fn(async () => ({ kind: 'remote', id: 'ssh:test', canonicalDisplayPath: '/remote/project', access: 'read-only' })),
|
|
dispose: vi.fn(async () => {})
|
|
} as unknown as WorkspaceAccess
|
|
const executionSpace: ExecutionSpaceDescriptor = { kind: 'ssh', hostId: crypto.randomUUID(), remoteRootPath: '/remote/project',
|
|
cacheIdentity: 'ssh:test', routeIdentity: 'ssh:project', workspaceAccess: remoteAccess }
|
|
const resolveSpace = vi.spyOn(ExecutionSpaceResolver.prototype, 'resolveProject').mockReturnValue(executionSpace)
|
|
const settings = { ...runtimeUpdateFixture(globalRoot).publicSettings, provider: 'model' as const,
|
|
apiKey: 'test-only', runtimeCustomization: defaultRuntimeSettings.runtimeCustomization }
|
|
const expertProfileId = crypto.randomUUID()
|
|
settings.modelProfiles.push({ ...settings.modelProfiles[0]!, id: expertProfileId, name: 'Expert connection' })
|
|
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
|
|
const body = JSON.parse(String(init?.body))
|
|
expect(body.tools).toBeUndefined()
|
|
return new Response('data: {"choices":[{"delta":{"content":"Remote summary"},"finish_reason":null}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
|
|
{ headers: { 'content-type': 'text/event-stream' } })
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
runtimeFactoryMocks.createDefaultModelRuntime.mockImplementation(actual.createDefaultModelRuntime)
|
|
const binding = { context: {} as never, describe: vi.fn(), call: vi.fn(), save: vi.fn(), readForSave: vi.fn() }
|
|
const browserTabId = browserTabIdSchema.parse(crypto.randomUUID())
|
|
const createLocal = vi.fn(async () => { throw new Error('SSH experts must not create Desktop model tools') })
|
|
const createRemote = vi.fn<SelectedRuntimeResolver['getRuntime']>(async () => ({
|
|
async *run(request: AgentExecutionRequest) {
|
|
expect(request.imageToolBinding).toBe(binding)
|
|
expect(request.knowledgeCapabilityToken).toBe('parent-scoped-token')
|
|
expect(request.browserTabId).toBe(browserTabId)
|
|
expect(request.prompt).toContain('Work in the project.')
|
|
yield { requestId: request.requestId, type: 'text', delta: 'Remote expert result' } as const
|
|
yield { requestId: request.requestId, type: 'done' } as const
|
|
},
|
|
releaseConversation: vi.fn(), dispose: vi.fn()
|
|
}) as never)
|
|
const factory = vi.fn(async (input: Parameters<SubagentService['run']>[0]) => {
|
|
expect(input.executionSpace).toBe(executionSpace)
|
|
return createSubagentRuntime(input, settings, createLocal, createRemote)
|
|
})
|
|
const service = new SubagentService(factory, { createTask: vi.fn(), updateTaskStatus: vi.fn(), appendTaskEvent: vi.fn() } as never)
|
|
const ordinary = { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run: vi.fn() }
|
|
const harness = createHarness(ordinary, undefined, service as unknown as Record<string, unknown>, true,
|
|
{ getRuntime: vi.fn(async () => ordinary) })
|
|
const expert = { id: crypto.randomUUID(), name: 'Remote writer', description: '', systemInstructions: 'Work in the project.',
|
|
modelProfileId: expertProfileId, routingKeywords: ['write'], enabled: true, createdAt: '', updatedAt: '' }
|
|
harness.getResolvedSettings.mockResolvedValue(settings)
|
|
harness.assistantDatabase.getProject.mockReturnValue({ id: projectId, rootPath: globalRoot,
|
|
executionSpace: { kind: 'ssh', hostId: executionSpace.hostId, remoteRootPath: executionSpace.remoteRootPath },
|
|
runtimeSelection: { provider } })
|
|
harness.assistantDatabase.getConversation.mockReturnValue({ id: 'remote-expert', messages: [] })
|
|
harness.contextManager.enrichRequest.mockImplementation(request => ({ ...request, imageToolBinding: binding,
|
|
knowledgeCapabilityToken: 'parent-scoped-token', browserTabId }))
|
|
harness.assistantDatabase.getExpert.mockReturnValue(expert)
|
|
harness.assistantDatabase.listExperts.mockReturnValue([expert, { ...expert, id: crypto.randomUUID(), routingKeywords: [] }])
|
|
try {
|
|
const requestId = crypto.randomUUID()
|
|
await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: 'remote-expert', projectId,
|
|
prompt: 'write a file', teamMode: true })
|
|
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
|
|
expect(factory).toHaveBeenCalledTimes(2)
|
|
expect(createRemote).toHaveBeenCalledWith({ provider, profileId: expert.modelProfileId }, executionSpace)
|
|
expect(createLocal).not.toHaveBeenCalled()
|
|
expect(binding.save).not.toHaveBeenCalled()
|
|
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
expect(ordinary.run).not.toHaveBeenCalled()
|
|
expect(await readdir(globalRoot)).toEqual([])
|
|
} finally {
|
|
await harness.dispose()
|
|
resolveSpace.mockRestore()
|
|
vi.unstubAllGlobals()
|
|
runtimeFactoryMocks.createDefaultModelRuntime.mockReset()
|
|
await rm(globalRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it.each([
|
|
{ persisted: false, prompt: '请做资料分析' },
|
|
{ persisted: true, prompt: 'Unmatched request' }
|
|
])(
|
|
'falls back to the ordinary runtime for ineligible smart routing %#',
|
|
async ({ prompt, persisted }) => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const run = vi.spyOn(runtime, 'run')
|
|
const subagentService = {
|
|
run: vi.fn(),
|
|
cancelAll: vi.fn(),
|
|
dispose: vi.fn(async () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
subagentService,
|
|
persisted
|
|
)
|
|
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
name: '研究专家',
|
|
description: '',
|
|
systemInstructions: 'Analyze.',
|
|
routingKeywords: ['资料分析'],
|
|
enabled: true,
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z'
|
|
}
|
|
])
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-fallback',
|
|
prompt,
|
|
smartRouting: true
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'completed'
|
|
)
|
|
)
|
|
expect(run).toHaveBeenCalledOnce()
|
|
expect(subagentService.run).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('allows ordinary text requests on runtimes without tool execution', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: false,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(request: AgentExecutionRequest) {
|
|
expect(request.trustedInstructions).not.toContain('Available GoodBuddy tools:')
|
|
expect(request.knowledgeCapabilityToken).toBeUndefined()
|
|
yield { requestId: request.requestId, type: 'text', delta: 'Text answer' }
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
|
|
await expect(
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
|
conversationId: 'conversation-1',
|
|
prompt: 'Explain this code'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
'3f496642-f47d-4e0a-8944-a32c77b0d6ef', 'completed'))
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('executes delegated writes with enabled note and config grants and no authorizer', async () => {
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(process.cwd())
|
|
const task = { id: crypto.randomUUID(), title: 'Delegated note', prompt: 'Create a note' }
|
|
let delivered: unknown
|
|
const server = createServer((request, response) => {
|
|
if (request.method === 'GET') {
|
|
response.setHeader('content-type', 'application/json')
|
|
response.end(JSON.stringify(task))
|
|
} else {
|
|
let body = ''
|
|
request.on('data', chunk => { body += chunk })
|
|
request.on('end', () => { delivered = JSON.parse(body); response.end('{}') })
|
|
}
|
|
})
|
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
const address = server.address() as { port: number }
|
|
vi.stubEnv('GOODBUDDY_DELEGATION_ENDPOINT', `http://127.0.0.1:${address.port}`)
|
|
vi.stubEnv('GOODBUDDY_DELEGATION_TOKEN', 'test-token')
|
|
const gateway = {
|
|
grant: vi.fn(() => 'delegated-capability'),
|
|
getAvailableToolNames: vi.fn(() => ['note_create', 'goodbuddy_config_apply']),
|
|
revoke: vi.fn()
|
|
}
|
|
const config = { revokeRequest: vi.fn(), takePendingReload: vi.fn(() => 'none'), clear: vi.fn() }
|
|
const runtime = {
|
|
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
|
|
async *run(request: AgentExecutionRequest, _signal: AbortSignal, authorize?: unknown) {
|
|
expect(authorize).toBeUndefined()
|
|
expect(request).not.toHaveProperty('workMode')
|
|
expect(request.knowledgeCapabilityToken).toBe('delegated-capability')
|
|
database.createMagicNote({ title: task.title })
|
|
yield { requestId: request.requestId, type: 'tool', callId: 'delegated-note',
|
|
name: 'note_create', state: 'completed', summary: 'Created note' } as const
|
|
yield { requestId: request.requestId, type: 'done' } as const
|
|
}
|
|
}
|
|
let harness: ReturnType<typeof createHarness> | undefined
|
|
try {
|
|
harness = createHarness(runtime, undefined, undefined, false, undefined, undefined,
|
|
gateway, true, config, { getEnabledBuiltinMcpServerIds: vi.fn(async () => ['magic-notes', 'goodbuddy-config']) },
|
|
undefined, undefined, database)
|
|
harness.getResolvedSettings.mockResolvedValue({ workspacePath: process.cwd() })
|
|
await vi.waitFor(() => expect(delivered).toEqual({ status: 'completed' }))
|
|
expect(database.listMagicNotes().map(note => note.title)).toContain(task.title)
|
|
expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(expect.any(String), [], expect.any(AbortSignal),
|
|
'write', { access: 'write', workspacePath: process.cwd() })
|
|
expect(gateway.revoke).toHaveBeenCalledWith('delegated-capability')
|
|
} finally {
|
|
await harness?.dispose()
|
|
vi.unstubAllEnvs()
|
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('routes Telegram settings and incoming messages through real channel handlers without modes', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-telegram-'))
|
|
const database = new AssistantDatabase(':memory:')
|
|
database.initialize(root)
|
|
database.ensureChannelProjects(root)
|
|
const routeConversation = vi.spyOn(database, 'getOrCreateRemoteConversation')
|
|
const settingsStore = new ChannelSettingsStore(join(root, 'channels.json'), {
|
|
isAvailable: () => true,
|
|
encrypt: value => Buffer.from(value),
|
|
decrypt: value => value.toString()
|
|
}, {})
|
|
const requests: AgentExecutionRequest[] = []
|
|
const runtime = {
|
|
capability: 'chat', supportsToolExecution: true,
|
|
async *run(request: AgentExecutionRequest, _signal: AbortSignal, authorize?: unknown) {
|
|
expect(authorize).toBeUndefined()
|
|
requests.push(request)
|
|
yield { requestId: request.requestId, type: 'text', delta: 'Telegram result' }
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
let delivered = false
|
|
const replies: unknown[] = []
|
|
electronMocks.fetch.mockImplementation(async (input, init) => {
|
|
const method = String(input).split('/').at(-1)
|
|
let result: unknown
|
|
if (method === 'getMe') result = { id: 456, is_bot: true, username: 'ipc_test_bot' }
|
|
else if (method === 'getWebhookInfo') result = { url: '' }
|
|
else if (method === 'getUpdates') {
|
|
result = delivered ? [] : [{
|
|
update_id: 789,
|
|
message: {
|
|
message_id: 1, date: Math.floor(Date.now() / 1000),
|
|
from: { id: 123, is_bot: false }, chat: { id: 123, type: 'private' },
|
|
text: '/execute preserve this literal prefix'
|
|
}
|
|
}]
|
|
delivered = true
|
|
} else if (method === 'sendMessage') {
|
|
replies.push(JSON.parse(String(init?.body)))
|
|
result = { message_id: 2 }
|
|
} else if (method === 'sendChatAction') result = true
|
|
else throw new Error(`Unexpected Telegram method: ${method}`)
|
|
return new Response(JSON.stringify({ ok: true, result }))
|
|
})
|
|
let harness: ReturnType<typeof createHarness> | undefined
|
|
try {
|
|
harness = createHarness(runtime, undefined, undefined, false, undefined, undefined,
|
|
undefined, false, undefined, undefined, undefined, undefined, database,
|
|
undefined, undefined, undefined, undefined, undefined, settingsStore)
|
|
const event = trustedEvent(harness.webContents)
|
|
const get = electronMocks.handlers.get(ipcChannels.channelSettingsGet)!
|
|
const apply = electronMocks.handlers.get(ipcChannels.channelSettingsApply)!
|
|
const test = electronMocks.handlers.get(ipcChannels.channelSettingsTest)!
|
|
const settings = {
|
|
enabled: false, secret: { action: 'replace', value: '456:test-token' },
|
|
allowedSenderIds: ['123'], allowGroupMessages: false
|
|
}
|
|
await expect(test(event, { channel: 'telegram', settings })).resolves.toEqual({
|
|
channel: 'telegram', ok: true, botUsername: 'ipc_test_bot'
|
|
})
|
|
await expect(get(event)).resolves.toMatchObject({ telegram: { secretConfigured: false } })
|
|
expect(delivered).toBe(false)
|
|
await expect(Promise.resolve().then(() => test(event, {
|
|
channel: 'telegram', settings: { ...settings, allowedSenderIds: ['username'] }
|
|
}))).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() => apply(event, { telegram: { ...settings, allowGroupMessages: true } }))).rejects.toThrow()
|
|
await expect(Promise.resolve().then(() => test({ sender: {}, senderFrame: harness!.webContents.mainFrame }, {
|
|
channel: 'telegram', settings
|
|
}))).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
|
await expect(apply(event, { telegram: settings })).resolves.toMatchObject({
|
|
telegram: { enabled: false, secretConfigured: true, allowedSenderIds: ['123'] }
|
|
})
|
|
await expect(test(event, { channel: 'telegram' })).resolves.toEqual({
|
|
channel: 'telegram', ok: true, botUsername: 'ipc_test_bot'
|
|
})
|
|
await apply(event, { telegram: { ...settings, enabled: true, secret: { action: 'keep' } } })
|
|
await vi.waitFor(() => expect(replies).toContainEqual({ chat_id: '123', text: 'Telegram result' }))
|
|
expect(requests).toHaveLength(1)
|
|
const request = requests[0]!
|
|
const project = database.listProjects().find(item => item.channel === 'telegram')!
|
|
expect(project).toMatchObject({ kind: 'channel', name: 'Telegram' })
|
|
expect(routeConversation).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
|
projectId: project.id, channel: 'telegram', accountId: '456',
|
|
externalConversationId: '456:123', conversationType: 'direct'
|
|
}))
|
|
expect(database.getConversation(request.conversationId!)).toMatchObject({
|
|
projectId: project.id,
|
|
remote: { channel: 'telegram', conversationType: 'direct' },
|
|
messages: [
|
|
expect.objectContaining({ role: 'user', content: '/execute preserve this literal prefix' }),
|
|
expect.objectContaining({ role: 'assistant', content: 'Telegram result' })
|
|
]
|
|
})
|
|
expect(database.getTask(request.requestId)).toMatchObject({
|
|
projectId: project.id, conversationId: request.conversationId,
|
|
origin: 'delegation', status: 'completed', instructions: '/execute preserve this literal prefix'
|
|
})
|
|
expect(request.prompt).toContain('/execute preserve this literal prefix')
|
|
expect(request).not.toHaveProperty('workMode')
|
|
expect(request).not.toHaveProperty('mode')
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
|
|
channel: 'telegram', status: { state: 'starting' }
|
|
})
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
|
|
channel: 'telegram', status: { state: 'running' }
|
|
})
|
|
const history = database.getConversation(request.conversationId!)
|
|
await expect(apply(event, { telegram: { ...settings, secret: { action: 'clear' } } })).resolves.toMatchObject({
|
|
telegram: { enabled: false, secretConfigured: false, status: { state: 'disabled' } }
|
|
})
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
|
|
channel: 'telegram', status: { state: 'disabled' }
|
|
})
|
|
expect((await settingsStore.resolve('telegram')).secret).toBeUndefined()
|
|
expect(database.getProject(project.id)).toEqual(project)
|
|
expect(database.getConversation(request.conversationId!)).toEqual(history)
|
|
} finally {
|
|
await harness?.dispose()
|
|
electronMocks.fetch.mockReset()
|
|
database.close()
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('bridges explanation-only channel requests without an authorizer', async () => {
|
|
let received:
|
|
| {
|
|
request: {
|
|
requestId: string
|
|
conversationId: string
|
|
prompt: string
|
|
}
|
|
authorize?: (request: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}) => Promise<string>
|
|
}
|
|
| undefined
|
|
const runtime = {
|
|
capability: 'chat',
|
|
async *run(
|
|
request: {
|
|
requestId: string
|
|
conversationId: string
|
|
prompt: string
|
|
},
|
|
_signal: AbortSignal,
|
|
authorize?: (request: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}) => Promise<string>
|
|
) {
|
|
received = { request, authorize }
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: '只读结果'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-1',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-1',
|
|
conversationType: 'direct',
|
|
text: '请只读分析',
|
|
mentioned: false
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toEqual({
|
|
status: 'completed',
|
|
output: '只读结果'
|
|
})
|
|
expect(received?.request).toMatchObject({
|
|
prompt: expect.stringContaining('请只读分析')
|
|
})
|
|
expect(received?.authorize).toBeUndefined()
|
|
expect(harness.getPolicySettings).not.toHaveBeenCalled()
|
|
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
|
expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: '企业微信远程请求',
|
|
instructions: '请只读分析',
|
|
origin: 'delegation'
|
|
})
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each([
|
|
['weixin', '微信 ClawBot'],
|
|
['wecom', '企业微信'],
|
|
['dingtalk', '钉钉'],
|
|
['telegram', 'Telegram']
|
|
] as const)(
|
|
'grants enabled Magic Notes write tools to %s channel requests',
|
|
async (channel, channelLabel) => {
|
|
let receivedRequest:
|
|
| {
|
|
knowledgeCapabilityToken?: string
|
|
prompt: string
|
|
trustedInstructions?: string
|
|
}
|
|
| undefined
|
|
const runtime = {
|
|
capability: 'chat',
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
prompt: string
|
|
trustedInstructions?: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: `call-${channel}-note-list`,
|
|
name: 'note_list',
|
|
state: 'completed',
|
|
summary: '读取笔记列表'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn<KnowledgeGrantMock>(() =>
|
|
'channel-notes-capability'
|
|
),
|
|
getAvailableToolNames: vi.fn(() => [
|
|
'note_list',
|
|
'note_get',
|
|
'note_search'
|
|
]),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
true
|
|
)
|
|
vi.mocked(
|
|
harness.assistantDatabase.listProjects
|
|
).mockReturnValue([
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000401',
|
|
name: channelLabel,
|
|
description: `${channelLabel}远程消息与受控任务`,
|
|
rootPath: 'C:\\ProjectWorkspace',
|
|
executionSpace: {
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
},
|
|
runtimeSelection: {
|
|
provider: 'model',
|
|
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000001' }
|
|
},
|
|
kind: 'channel',
|
|
channel,
|
|
status: 'active',
|
|
createdAt: '2026-08-04T00:00:00.000Z',
|
|
updatedAt: '2026-08-04T00:00:00.000Z'
|
|
}
|
|
])
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel,
|
|
eventId: `event-${channel}-notes`,
|
|
senderId: 'user-1',
|
|
conversationId: `conversation-${channel}-notes`,
|
|
conversationType: 'direct',
|
|
text: '读取我的笔记',
|
|
mentioned: false
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toMatchObject({ status: 'completed' })
|
|
const requestId =
|
|
vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0]
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
|
requestId,
|
|
[],
|
|
expect.any(AbortSignal),
|
|
'write'
|
|
)
|
|
expect(receivedRequest).toMatchObject({
|
|
knowledgeCapabilityToken: 'channel-notes-capability',
|
|
trustedInstructions: expect.stringContaining(
|
|
'note_list, note_get, note_search'
|
|
)
|
|
})
|
|
expect(receivedRequest?.prompt).toContain('读取我的笔记')
|
|
expect(receivedRequest).not.toHaveProperty('workMode')
|
|
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
|
|
'channel-notes-capability'
|
|
)
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('persists remote media and passes it through the existing context path', async () => {
|
|
let receivedRequest:
|
|
| {
|
|
contextIds?: string[]
|
|
prompt: string
|
|
}
|
|
| undefined
|
|
const runtime = {
|
|
capability: 'chat',
|
|
async *run(request: {
|
|
requestId: string
|
|
contextIds?: string[]
|
|
prompt: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: '图片已分析'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-media',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-media',
|
|
conversationType: 'direct',
|
|
text: '',
|
|
attachments: [
|
|
{
|
|
name: '现场.png',
|
|
mimeType: 'image/png',
|
|
size: 4,
|
|
kind: 'image',
|
|
dataBase64: 'iVBORw=='
|
|
}
|
|
],
|
|
mentioned: false
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toEqual({
|
|
status: 'completed',
|
|
output: '图片已分析'
|
|
})
|
|
expect(
|
|
harness.contextManager.ingestRemoteAttachment
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: '现场.png' })
|
|
)
|
|
expect(receivedRequest?.contextIds).toEqual([
|
|
'00000000-0000-4000-8000-000000000498'
|
|
])
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteConversationMessage
|
|
).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
role: 'user',
|
|
content: '请分析我发送的附件。',
|
|
attachments: [
|
|
expect.objectContaining({ name: '现场.png' })
|
|
]
|
|
})
|
|
)
|
|
expect(harness.contextManager.remove).toHaveBeenCalledWith(
|
|
'00000000-0000-4000-8000-000000000498'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('returns a generated image only as a current-task channel attachment', async () => {
|
|
const image = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
|
])
|
|
const runtime = {
|
|
capability: 'image-generation',
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'generated-image',
|
|
mimeType: 'image/png',
|
|
data: image.toString('base64'),
|
|
title: '结果图'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
const outboxDatabase = new AssistantDatabase(':memory:')
|
|
outboxDatabase.initialize('C:\\Workspace')
|
|
const outbox = new SqliteChannelOutbox(outboxDatabase)
|
|
let inbound!: ChannelInboundHandler
|
|
const send = vi.fn<ChannelDriver['send']>(async () => undefined)
|
|
const service = new ChannelService({
|
|
channel: 'wecom',
|
|
start: (handler) => { inbound = handler },
|
|
send,
|
|
stop: () => undefined
|
|
}, executor, { allowedSenderIds: ['user-1'], outbox })
|
|
try {
|
|
await service.start()
|
|
await inbound(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-generated-image',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-generated-image',
|
|
conversationType: 'direct',
|
|
text: '生成结果图',
|
|
mentioned: false
|
|
},
|
|
() => undefined
|
|
)
|
|
await vi.waitFor(() => expect(send).toHaveBeenCalledOnce())
|
|
expect(send.mock.calls[0]?.[0]).toMatchObject({
|
|
status: 'completed',
|
|
attachments: [
|
|
{
|
|
name: '结果图.png',
|
|
mimeType: 'image/png',
|
|
size: image.byteLength,
|
|
kind: 'image',
|
|
dataBase64: image.toString('base64')
|
|
}
|
|
]
|
|
})
|
|
expect(send.mock.calls[0]?.[0]).not.toHaveProperty('artifactIds')
|
|
expect(outbox.listUndelivered()).toEqual([])
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteConversationMessage
|
|
).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
role: 'assistant',
|
|
artifactIds: [
|
|
'00000000-0000-4000-8000-000000000499'
|
|
]
|
|
})
|
|
)
|
|
} finally {
|
|
await service.stop()
|
|
outboxDatabase.close()
|
|
await harness.dispose()
|
|
}
|
|
})
|
|
|
|
it('creates a bounded result file only when the remote user explicitly requests one', async () => {
|
|
const runtime = {
|
|
capability: 'chat',
|
|
async *run(request: { requestId: string }) {
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: '# 本周报告\n\n已完成。'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
const result = await executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-result-file',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-result-file',
|
|
conversationType: 'direct',
|
|
text: '请生成一个文件,总结本周进展',
|
|
mentioned: false
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
expect(result).toMatchObject({
|
|
status: 'completed',
|
|
output: '# 本周报告\n\n已完成。',
|
|
attachments: [
|
|
{
|
|
name: 'GoodBuddy-结果.md',
|
|
mimeType: 'text/markdown',
|
|
kind: 'file'
|
|
}
|
|
]
|
|
})
|
|
expect(
|
|
Buffer.from(
|
|
result.attachments?.[0]?.dataBase64 ?? '',
|
|
'base64'
|
|
).toString('utf8')
|
|
).toBe('# 本周报告\n\n已完成。')
|
|
expect(
|
|
harness.assistantDatabase.createTextArtifact
|
|
).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
name: 'Runtime status resolution fails',
|
|
getStatus: () => Promise.reject(new Error('状态查询失败')),
|
|
expectedError: '状态查询失败'
|
|
},
|
|
{
|
|
name: 'Runtime is unavailable',
|
|
getStatus: () =>
|
|
Promise.resolve({
|
|
id: 'model',
|
|
label: 'Direct model',
|
|
available: false,
|
|
supportsToolExecution: false,
|
|
detail: 'Model unavailable'
|
|
}),
|
|
expectedError:
|
|
'Model unavailable'
|
|
}
|
|
])(
|
|
'finalizes remote requests consistently when $name',
|
|
async ({ getStatus, expectedError }) => {
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(getStatus),
|
|
run: vi.fn()
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-execute-unavailable',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-execute-unavailable',
|
|
conversationType: 'direct',
|
|
text: '/execute 更新 README',
|
|
mentioned: false
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toEqual({
|
|
status: 'failed',
|
|
error: expectedError
|
|
})
|
|
|
|
const remoteTask =
|
|
harness.assistantDatabase.createTask.mock.calls[0]?.[0]
|
|
expect(remoteTask).toEqual(
|
|
expect.objectContaining({
|
|
projectId: '00000000-0000-4000-8000-000000000401',
|
|
conversationId: '00000000-0000-4000-8000-000000000402',
|
|
origin: 'delegation',
|
|
visible: false
|
|
})
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(remoteTask?.id, 'failed', expectedError)
|
|
expect(
|
|
harness.assistantDatabase.appendRemoteConversationMessage
|
|
).toHaveBeenLastCalledWith({
|
|
conversationId: '00000000-0000-4000-8000-000000000402',
|
|
role: 'assistant',
|
|
content: expectedError,
|
|
status: '执行不可用'
|
|
})
|
|
const activities = harness.webContents.send.mock.calls
|
|
.filter(
|
|
([channel]) =>
|
|
channel === ipcChannels.remoteChannelActivity
|
|
)
|
|
.map(([, activity]) => activity)
|
|
expect(activities).toHaveLength(2)
|
|
expect(activities[0]).toEqual(
|
|
expect.objectContaining({
|
|
requestId: remoteTask?.id,
|
|
kind: 'request',
|
|
status: 'running'
|
|
})
|
|
)
|
|
expect(activities[1]).toEqual({
|
|
requestId: remoteTask?.id,
|
|
conversationId: '00000000-0000-4000-8000-000000000402',
|
|
projectId: '00000000-0000-4000-8000-000000000401',
|
|
projectName: '企业微信',
|
|
channel: 'wecom',
|
|
kind: 'result',
|
|
title: '企业微信远程执行不可用',
|
|
detail: expectedError,
|
|
status: 'failed'
|
|
})
|
|
expect(runtime.run).not.toHaveBeenCalled()
|
|
await harness.dispose()
|
|
}
|
|
)
|
|
|
|
it('runs channel tools without a general authorizer and preserves old prefixes literally', async () => {
|
|
let receivedAuthorize: unknown
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(async () => ({
|
|
id: 'model',
|
|
label: 'Direct model',
|
|
available: true,
|
|
supportsToolExecution: true
|
|
})),
|
|
async *run(
|
|
request: { requestId: string; prompt: string },
|
|
_signal: AbortSignal,
|
|
authorize: unknown
|
|
) {
|
|
receivedAuthorize = authorize
|
|
expect(request.prompt).toContain('/execute 更新 README')
|
|
yield { requestId: request.requestId, type: 'tool', callId: 'channel-write',
|
|
name: 'workspace_apply_patch', state: 'completed', summary: 'Updated README.md' }
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: '执行完成'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
const reportProgress = vi.fn(async () => undefined)
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-execute-direct',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-execute-direct',
|
|
conversationType: 'direct',
|
|
text: '/execute 更新 README',
|
|
mentioned: false
|
|
},
|
|
new AbortController().signal,
|
|
reportProgress
|
|
)
|
|
).resolves.toEqual({
|
|
status: 'completed',
|
|
output: '执行完成'
|
|
})
|
|
expect(receivedAuthorize).toBeUndefined()
|
|
expect(reportProgress).not.toHaveBeenCalled()
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
'waiting_approval'
|
|
)
|
|
// Channel conversations inherit the project's choice instead of copying it.
|
|
expect(
|
|
harness.assistantDatabase.getOrCreateRemoteConversation
|
|
).toHaveBeenCalledWith(
|
|
expect.not.objectContaining({ runtimeSelection: expect.anything() })
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('grants Magic Notes write tools to channel requests', async () => {
|
|
let receivedRequest:
|
|
| {
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
}
|
|
| undefined
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(async () => ({
|
|
id: 'model',
|
|
label: 'Direct model',
|
|
available: true,
|
|
supportsToolExecution: true
|
|
})),
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn<KnowledgeGrantMock>(() =>
|
|
'channel-notes-write-capability'
|
|
),
|
|
getAvailableToolNames: vi.fn(() => [
|
|
'note_list',
|
|
'note_get',
|
|
'note_search',
|
|
'note_create',
|
|
'note_update',
|
|
'note_entry_create',
|
|
'note_entry_update',
|
|
'note_entry_delete',
|
|
'note_delete'
|
|
]),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
true
|
|
)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-execute-notes',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-execute-notes',
|
|
conversationType: 'direct',
|
|
text: '/execute 创建一条笔记',
|
|
mentioned: false
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toMatchObject({ status: 'completed' })
|
|
const requestId =
|
|
vi.mocked(knowledgeGateway.grant).mock.calls[0]?.[0]
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
|
requestId,
|
|
[],
|
|
expect.any(AbortSignal),
|
|
'write'
|
|
)
|
|
expect(receivedRequest).toMatchObject({
|
|
knowledgeCapabilityToken: 'channel-notes-write-capability',
|
|
trustedInstructions: expect.stringContaining('note_create')
|
|
})
|
|
expect(receivedRequest?.trustedInstructions).toContain(
|
|
'note_delete'
|
|
)
|
|
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
|
|
'channel-notes-write-capability'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('does not grant or advertise Magic Notes to external OpenCode channels', async () => {
|
|
let receivedRequest:
|
|
| {
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
}
|
|
| undefined
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
supportsScopedDataTools: false,
|
|
getStatus: vi.fn(async () => ({
|
|
id: 'opencode',
|
|
label: 'External OpenCode',
|
|
available: true,
|
|
supportsToolExecution: true
|
|
})),
|
|
async *run(request: {
|
|
requestId: string
|
|
knowledgeCapabilityToken?: string
|
|
trustedInstructions?: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn<KnowledgeGrantMock>(() => 'must-not-be-granted'),
|
|
getAvailableToolNames: vi.fn(() => ['note_list']),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
selectedRuntime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
true
|
|
)
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
const result = await executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-external-opencode',
|
|
senderId: 'friend',
|
|
conversationId: 'external-opencode',
|
|
conversationType: 'direct',
|
|
text: '读取我的笔记',
|
|
mentioned: true
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
if (result.status === 'failed') {
|
|
throw new Error(result.error)
|
|
}
|
|
expect(result).toMatchObject({ status: 'completed' })
|
|
|
|
expect(knowledgeGateway.grant).not.toHaveBeenCalled()
|
|
expect(receivedRequest?.knowledgeCapabilityToken).toBeUndefined()
|
|
expect(receivedRequest?.trustedInstructions).not.toContain(
|
|
'note_list'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('uses enabled runtime capabilities for remote Agent requests', async () => {
|
|
let receivedRequest:
|
|
| {
|
|
trustedInstructions?: string
|
|
}
|
|
| undefined
|
|
const selectedRuntime = {
|
|
runtimeId: 'opencode',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: {
|
|
requestId: string
|
|
trustedInstructions?: string
|
|
}) {
|
|
receivedRequest = request
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => selectedRuntime),
|
|
getStatus: vi.fn(),
|
|
releaseConversation: vi.fn(async () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
{
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
run: vi.fn()
|
|
},
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
const projectId = '00000000-0000-4000-8000-000000000451'
|
|
harness.getApplicationSettings.mockResolvedValue({
|
|
magicNotesEnabled: false,
|
|
remoteProjectsEnabled: true
|
|
} as never)
|
|
harness.assistantDatabase.getProject.mockReturnValue({
|
|
id: projectId,
|
|
name: 'Remote project',
|
|
description: '',
|
|
rootPath: '/srv/project',
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: '00000000-0000-4000-8000-000000000452',
|
|
remoteRootPath: '/srv/project'
|
|
},
|
|
runtimeSelection: { provider: 'opencode' },
|
|
kind: 'user',
|
|
status: 'active',
|
|
createdAt: '2026-08-25T00:00:00.000Z',
|
|
updatedAt: '2026-08-25T00:00:00.000Z'
|
|
} as never)
|
|
|
|
await harness.handler?.(
|
|
trustedEvent(harness.webContents),
|
|
{
|
|
requestId: '00000000-0000-4000-8000-000000000453',
|
|
conversationId: 'conversation-remote-ask',
|
|
projectId,
|
|
prompt: 'Read marker.txt',
|
|
runtimeSelection: { provider: 'opencode' },
|
|
knowledgeLibraryIds: []
|
|
}
|
|
)
|
|
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
|
|
{ provider: 'opencode' },
|
|
expect.objectContaining({
|
|
kind: 'ssh',
|
|
remoteRootPath: '/srv/project'
|
|
})
|
|
)
|
|
expect(receivedRequest).not.toHaveProperty('workMode')
|
|
expect(receivedRequest?.trustedInstructions).toContain(
|
|
'enabled capabilities'
|
|
)
|
|
expect(receivedRequest?.trustedInstructions).toContain(
|
|
'Respect requests for explanation only'
|
|
)
|
|
expect(receivedRequest?.trustedInstructions).not.toContain(
|
|
'Do not call tools'
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('routes channel requests to a configured Agent Runtime without an approval callback', async () => {
|
|
let receivedAuthorize: unknown = 'not-called'
|
|
const configuredProfileId =
|
|
'00000000-0000-4000-8000-000000000019'
|
|
const selectedRuntime = {
|
|
runtimeId: 'continue',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(async () => ({
|
|
id: 'continue',
|
|
label: 'Continue',
|
|
available: true,
|
|
supportsToolExecution: true
|
|
})),
|
|
async *run(
|
|
request: { requestId: string },
|
|
_signal: AbortSignal,
|
|
authorize?: unknown
|
|
) {
|
|
receivedAuthorize = authorize
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta: 'Continue 已执行'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const selectedRuntimes = {
|
|
getRuntime: vi.fn(async () => selectedRuntime),
|
|
getStatus: vi.fn(),
|
|
releaseConversation: vi.fn(async () => undefined)
|
|
}
|
|
const harness = createHarness(
|
|
{
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
run: vi.fn()
|
|
},
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
selectedRuntimes
|
|
)
|
|
harness.getResolvedSettings.mockResolvedValue({
|
|
subagentSmartRoutingEnabled: false,
|
|
continueModelProfile: { id: configuredProfileId },
|
|
modelProfiles: [{
|
|
id: configuredProfileId, baseUrl: 'https://models.example/v1',
|
|
protocol: 'openai-chat-completions', authentication: 'none'
|
|
}]
|
|
})
|
|
vi.mocked(
|
|
harness.assistantDatabase.listProjects
|
|
).mockReturnValue([
|
|
{
|
|
id: '00000000-0000-4000-8000-000000000401',
|
|
name: '企业微信',
|
|
description: '企业微信远程消息与受控任务',
|
|
rootPath: 'C:\\ProjectWorkspace',
|
|
executionSpace: {
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
},
|
|
runtimeSelection: { provider: 'continue' },
|
|
kind: 'channel',
|
|
channel: 'wecom',
|
|
status: 'active',
|
|
createdAt: '2026-08-04T00:00:00.000Z',
|
|
updatedAt: '2026-08-04T00:00:00.000Z'
|
|
}
|
|
])
|
|
const executor = channelMocks.executor
|
|
if (!executor) {
|
|
throw new Error('Expected channel executor')
|
|
}
|
|
|
|
await expect(
|
|
executor(
|
|
{
|
|
channel: 'wecom',
|
|
eventId: 'event-execute-runtime',
|
|
senderId: 'user-1',
|
|
conversationId: 'conversation-execute-runtime',
|
|
conversationType: 'direct',
|
|
text: '更新 README',
|
|
mentioned: false
|
|
},
|
|
new AbortController().signal
|
|
)
|
|
).resolves.toEqual({
|
|
status: 'completed',
|
|
output: 'Continue 已执行'
|
|
})
|
|
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
|
|
{ provider: 'continue', profileId: configuredProfileId },
|
|
expect.objectContaining({
|
|
kind: 'local',
|
|
rootPath: 'C:\\ProjectWorkspace'
|
|
})
|
|
)
|
|
expect(
|
|
harness.assistantDatabase.getOrCreateRemoteConversation
|
|
).toHaveBeenCalledWith(
|
|
expect.not.objectContaining({ runtimeSelection: expect.anything() })
|
|
)
|
|
expect(receivedAuthorize).toBeUndefined()
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('stops channels before clearing other IPC resources', async () => {
|
|
const order: string[] = []
|
|
channelMocks.stop.mockImplementationOnce(async () => {
|
|
order.push('channel-stop')
|
|
})
|
|
const harness = createHarness({
|
|
capability: 'chat',
|
|
run: vi.fn()
|
|
})
|
|
harness.contextManager.clear.mockImplementation(() => {
|
|
order.push('context-clear')
|
|
})
|
|
|
|
await harness.dispose()
|
|
|
|
expect(order).toEqual(['channel-stop', 'context-clear'])
|
|
})
|
|
|
|
it('runs direct-model tools without approval events or an authorizer', async () => {
|
|
let receivedAuthorize:
|
|
| ((
|
|
request: {
|
|
scopeKey: string
|
|
title: string
|
|
description: string
|
|
}
|
|
) => Promise<string>)
|
|
| undefined
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: true,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run(
|
|
request: { requestId: string },
|
|
_signal: AbortSignal,
|
|
authorize: typeof receivedAuthorize
|
|
) {
|
|
receivedAuthorize = authorize
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'tool',
|
|
callId: 'call-1',
|
|
name: '读取工作区文本',
|
|
state: 'completed',
|
|
summary: '直连模型工具已完成'
|
|
}
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: '读取文件'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
expect(receivedAuthorize).toBeUndefined()
|
|
expect(harness.getPolicySettings).not.toHaveBeenCalled()
|
|
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).not.toHaveBeenCalledWith(requestId, 'waiting_approval')
|
|
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({ type: 'approval' })
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('allows channel text-only models without tool execution support', async () => {
|
|
let receivedAuthorize: unknown
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: false,
|
|
getStatus: vi.fn(async () => ({ available: true, supportsToolExecution: false })),
|
|
dispose: vi.fn(),
|
|
async *run(
|
|
request: AgentExecutionRequest,
|
|
_signal: AbortSignal,
|
|
authorize: unknown
|
|
) {
|
|
receivedAuthorize = authorize
|
|
expect(request.prompt).toContain('/ask Explain this code')
|
|
expect(request.trustedInstructions).not.toContain('Available GoodBuddy tools:')
|
|
yield { requestId: request.requestId, type: 'text', delta: 'Text answer' }
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
await expect(channelMocks.executor!({
|
|
channel: 'wecom', eventId: 'text-only', senderId: 'user-1',
|
|
conversationId: 'text-only', conversationType: 'direct',
|
|
text: '/ask Explain this code', mentioned: false
|
|
}, new AbortController().signal)).resolves.toEqual({ status: 'completed', output: 'Text answer' })
|
|
expect(receivedAuthorize).toBeUndefined()
|
|
expect(harness.getPolicySettings).not.toHaveBeenCalled()
|
|
expect(harness.getResolvedSettings).not.toHaveBeenCalled()
|
|
expect(harness.webContents.send).not.toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({ type: 'approval' })
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('grants local config write access without a standalone native authorizer', async () => {
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6e1'
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'config-capability'),
|
|
getAvailableToolNames: vi.fn(() => [
|
|
'goodbuddy_config_capabilities',
|
|
'goodbuddy_config_get',
|
|
'goodbuddy_config_plan',
|
|
'goodbuddy_config_apply'
|
|
]),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const goodbuddyConfigService = {
|
|
takePendingReload: vi.fn(() => 'none'),
|
|
revokeRequest: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
false,
|
|
goodbuddyConfigService
|
|
)
|
|
harness.getResolvedSettings.mockResolvedValue({
|
|
workspacePath: 'C:\\Workspace'
|
|
})
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: `conversation-${requestId}`,
|
|
prompt: '删除 MCP'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestId, 'completed')
|
|
)
|
|
expect(knowledgeGateway.grant).toHaveBeenCalledWith(
|
|
requestId,
|
|
[],
|
|
expect.any(AbortSignal),
|
|
'none',
|
|
{ access: 'write', workspacePath: 'C:\\Workspace' }
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('coalesces config reload until all active requests finish', async () => {
|
|
const completions = new Map<string, () => void>()
|
|
const runtime = {
|
|
runtimeId: 'model',
|
|
capability: 'chat',
|
|
supportsToolExecution: true,
|
|
async *run(request: { requestId: string }) {
|
|
await new Promise<void>((resolve) => {
|
|
completions.set(request.requestId, resolve)
|
|
})
|
|
yield { requestId: request.requestId, type: 'done' }
|
|
}
|
|
}
|
|
const knowledgeGateway = {
|
|
grant: vi.fn(() => 'config-capability'),
|
|
getAvailableToolNames: vi.fn(() => [
|
|
'goodbuddy_config_capabilities'
|
|
]),
|
|
drainReferences: vi.fn(() => []),
|
|
revoke: vi.fn()
|
|
}
|
|
const goodbuddyConfigService = {
|
|
takePendingReload: vi.fn(() => 'after-current-request'),
|
|
revokeRequest: vi.fn(),
|
|
clear: vi.fn()
|
|
}
|
|
const harness = createHarness(
|
|
runtime,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
undefined,
|
|
undefined,
|
|
knowledgeGateway,
|
|
false,
|
|
goodbuddyConfigService
|
|
)
|
|
harness.getResolvedSettings.mockResolvedValue({
|
|
workspacePath: 'C:\\Workspace'
|
|
})
|
|
const requestIds = [
|
|
'3f496642-f47d-4e0a-8944-a32c77b0d6e3',
|
|
'3f496642-f47d-4e0a-8944-a32c77b0d6e4'
|
|
]
|
|
for (const requestId of requestIds) {
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: `conversation-${requestId}`,
|
|
prompt: '配置 Runtime'
|
|
})
|
|
}
|
|
await vi.waitFor(() => expect(completions.size).toBe(2))
|
|
|
|
completions.get(requestIds[0]!)?.()
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestIds[0], 'completed')
|
|
)
|
|
expect(harness.onRuntimeSettingsChanged).not.toHaveBeenCalled()
|
|
|
|
completions.get(requestIds[1]!)?.()
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
harness.assistantDatabase.updateTaskStatus
|
|
).toHaveBeenCalledWith(requestIds[1], 'completed')
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
|
|
)
|
|
expect(goodbuddyConfigService.takePendingReload).toHaveBeenCalledTimes(
|
|
2
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
|
|
it('preserves bounded runtime errors for persistence and renderer delivery', async () => {
|
|
const fetchCause = Object.assign(
|
|
new Error('connect ECONNREFUSED 127.0.0.1:11434'),
|
|
{
|
|
code: 'ECONNREFUSED',
|
|
syscall: 'connect',
|
|
address: '127.0.0.1',
|
|
port: 11434
|
|
}
|
|
)
|
|
const expectedError = [
|
|
'fetch failed',
|
|
'cause:',
|
|
'connect ECONNREFUSED 127.0.0.1:11434',
|
|
'code: ECONNREFUSED',
|
|
'syscall: connect',
|
|
'address: 127.0.0.1',
|
|
'port: 11434'
|
|
].join('\n')
|
|
const runtime = {
|
|
capability: 'chat',
|
|
requiresToolApproval: false,
|
|
supportsToolExecution: false,
|
|
getStatus: vi.fn(),
|
|
dispose: vi.fn(),
|
|
async *run() {
|
|
yield* []
|
|
throw new TypeError('fetch failed', { cause: fetchCause })
|
|
}
|
|
}
|
|
const harness = createHarness(runtime)
|
|
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
|
|
|
harness.handler?.(trustedEvent(harness.webContents), {
|
|
requestId,
|
|
conversationId: 'conversation-1',
|
|
prompt: 'ask'
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
|
|
requestId,
|
|
'failed',
|
|
expectedError
|
|
)
|
|
)
|
|
expect(harness.webContents.send).toHaveBeenCalledWith(
|
|
ipcChannels.agentEvent,
|
|
expect.objectContaining({
|
|
message: expectedError
|
|
})
|
|
)
|
|
await harness.dispose()
|
|
})
|
|
})
|
|
|
|
describe('registerIpcHandlers Magic Notes analysis', () => {
|
|
afterEach(() => {
|
|
electronMocks.handlers.clear()
|
|
vi.clearAllMocks()
|
|
channelMocks.stop.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('persists comments and usage without exposing an analysis task', async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-magic-ipc-'))
|
|
const database = new AssistantDatabase(
|
|
join(directory, 'assistant.sqlite')
|
|
)
|
|
database.initialize('C:\\Workspace')
|
|
const note = database.createMagicNote({
|
|
title: 'API 回归测试'
|
|
})
|
|
const withEntry = database.createMagicNoteEntry({
|
|
noteId: note.id,
|
|
content: {
|
|
version: 1,
|
|
ops: [{ insert: '请完成发布清单。\n' }]
|
|
},
|
|
plainText: '请完成发布清单。'
|
|
})
|
|
const entry = withEntry.entries[0]!
|
|
const releaseConversation = vi.fn(async () => undefined)
|
|
const disposeRuntime = vi.fn(async () => undefined)
|
|
let analysisRequestId = ''
|
|
const analysisRuntime = {
|
|
releaseConversation,
|
|
dispose: disposeRuntime,
|
|
async *run(request: { requestId: string }) {
|
|
analysisRequestId = request.requestId
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'model-usage',
|
|
callId: 'magic-call-1',
|
|
runtime: 'model',
|
|
provider: 'openai',
|
|
model: 'test-model',
|
|
inputTokens: 20,
|
|
outputTokens: 10,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0
|
|
} as const
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'text',
|
|
delta:
|
|
'{"comments":[{"kind":"suggestion","content":"先核对发布材料。"}]}'
|
|
} as const
|
|
yield {
|
|
requestId: request.requestId,
|
|
type: 'done'
|
|
} as const
|
|
}
|
|
}
|
|
runtimeFactoryMocks.createDefaultModelRuntime.mockReturnValue(
|
|
analysisRuntime
|
|
)
|
|
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
|
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
|
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
|
send: vi.fn()
|
|
}
|
|
const window = {
|
|
webContents,
|
|
isDestroyed: vi.fn(() => false),
|
|
isMaximized: vi.fn(() => false),
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
}
|
|
const disposeHandlers = registerIpcHandlers(
|
|
window as never,
|
|
{ capability: 'text' } as never,
|
|
'CommandOrControl+Shift+Space',
|
|
{
|
|
getResolvedSettings: vi.fn(async () => ({
|
|
workspacePath: 'C:\\Workspace'
|
|
}))
|
|
} as never,
|
|
{} as never,
|
|
{ clear: vi.fn() } as never,
|
|
{} as never,
|
|
database,
|
|
{} as never,
|
|
vi.fn(async () => undefined)
|
|
)
|
|
const event = {
|
|
sender: webContents,
|
|
senderFrame: webContents.mainFrame
|
|
}
|
|
|
|
try {
|
|
await expect(
|
|
electronMocks.handlers.get(ipcChannels.magicNotesAnalyze)?.(
|
|
event,
|
|
{
|
|
entryId: entry.id,
|
|
requestId: '00000000-0000-4000-8000-000000000701',
|
|
direction: 'general',
|
|
format: 'structured'
|
|
}
|
|
)
|
|
).resolves.toMatchObject({
|
|
entries: [
|
|
{
|
|
comments: [
|
|
expect.objectContaining({
|
|
content: '先核对发布材料。'
|
|
})
|
|
]
|
|
}
|
|
]
|
|
})
|
|
expect(database.listTasks()).toEqual([])
|
|
expect(database.getTokenUsageSummary().records).toEqual([
|
|
expect.objectContaining({
|
|
requestId: analysisRequestId,
|
|
provider: 'openai',
|
|
model: 'test-model',
|
|
totalTokens: 30
|
|
})
|
|
])
|
|
expect(releaseConversation).toHaveBeenCalledWith(
|
|
`magic-notes:${entry.id}`
|
|
)
|
|
expect(disposeRuntime).toHaveBeenCalledOnce()
|
|
} finally {
|
|
await disposeHandlers()
|
|
database.close()
|
|
await rm(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|