Files
goodbuddy/src/main/ipc.test.ts
T
2026-08-05 19:40:40 +08:00

1278 lines
36 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ipcChannels } from '../shared/ipc-channels'
import type { BrowserLiveState } from '../shared/contracts'
import { registerIpcHandlers } from './ipc'
type InvokeHandler = (event: unknown, input?: unknown) => unknown
const electronMocks = vi.hoisted(() => {
const handlers = new Map<string, InvokeHandler>()
return {
handlers,
handle: vi.fn((channel: string, handler: InvokeHandler) => {
handlers.set(channel, handler)
}),
removeHandler: vi.fn((channel: string) => {
handlers.delete(channel)
})
}
})
const channelMocks = vi.hoisted(() => ({
executor: undefined as
| ((
message: {
channel: string
eventId: string
senderId: string
conversationId: string
conversationType: 'direct' | 'group'
text: string
mentioned: boolean
workMode: 'ask' | 'plan'
},
signal: AbortSignal
) => Promise<{
status: string
output?: string
error?: string
}>)
| undefined,
stop: vi.fn(async () => undefined)
}))
describe('registerIpcHandlers computer capabilities', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
channelMocks.stop.mockResolvedValue(undefined)
})
it('validates computer capability requests and restricts them to the trusted renderer', async () => {
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const snapshot = {
skills: [],
mcpServers: [],
computerCapabilities: [],
browserProfiles: { profiles: [], defaultProfileId: null }
}
const capabilityService = {
setComputerCapabilityEnabled: vi.fn(async () => snapshot),
createBrowserProfile: vi.fn(async () => snapshot),
diagnoseComputerCapability: vi.fn(async () => ({
capabilityId: 'host-browser-control',
status: 'disabled',
checkedAt: '2026-08-05T00:00:00.000Z',
checks: []
}))
}
const onRuntimeSettingsChanged = vi.fn(async () => {})
const releaseConversation = vi.fn(async () => {})
let browserStateListener:
| ((state: BrowserLiveState) => void)
| undefined
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
capabilityService as never,
{ clear: vi.fn() } as never,
{} as never,
{ claimDueSchedules: vi.fn(() => []) } as never,
{ clear: vi.fn() } as never,
{} as never,
onRuntimeSettingsChanged,
undefined,
{
releaseConversation,
onState: (listener) => {
browserStateListener = listener
return vi.fn()
}
}
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
await expect(
electronMocks.handlers.get(
ipcChannels.capabilitiesToggleComputer
)?.(event, {
capabilityId: 'host-browser-control',
enabled: true
})
).resolves.toEqual(snapshot)
expect(
capabilityService.setComputerCapabilityEnabled
).toHaveBeenCalledWith('host-browser-control', true)
expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce()
browserStateListener?.({
conversationId: 'browser-conversation',
status: 'ready',
updatedAt: 1
})
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.browserState,
expect.objectContaining({
conversationId: 'browser-conversation',
status: 'ready'
})
)
await expect(
electronMocks.handlers.get(ipcChannels.browserStop)?.(event, {
conversationId: 'browser-conversation'
})
).resolves.toBeUndefined()
expect(releaseConversation).toHaveBeenCalledWith(
'browser-conversation'
)
expect(() =>
electronMocks.handlers.get(
ipcChannels.capabilitiesCreateBrowserProfile
)?.(event, {
name: '工作配置',
executable: 'C:\\unsafe.exe'
})
).toThrow()
expect(capabilityService.createBrowserProfile).not.toHaveBeenCalled()
expect(() =>
electronMocks.handlers.get(
ipcChannels.capabilitiesDiagnoseComputer
)?.(
{
sender: {},
senderFrame: webContents.mainFrame
},
'host-browser-control'
)
).toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
})
})
vi.mock('electron', () => ({
app: {
getName: vi.fn(() => 'GoodBuddy'),
getVersion: vi.fn(() => '0.1.0')
},
BrowserWindow: class {},
dialog: {},
ipcMain: {
handle: electronMocks.handle,
removeHandler: electronMocks.removeHandler
},
Notification: class {
static isSupported(): boolean {
return false
}
}
}))
vi.mock('./assistant/heartbeat-service', () => ({
HeartbeatService: class {
async processDue(): Promise<void> {}
}
}))
vi.mock('./channels/channel-env', () => ({
isReadOnlyChannelMessage: (message: { workMode: string }) =>
message.workMode === 'ask' || message.workMode === 'plan',
startEnvironmentChannels: vi.fn(
(options: { executor: typeof channelMocks.executor }) => {
channelMocks.executor = options.executor
return [
{
start: vi.fn(async () => undefined),
stop: channelMocks.stop
}
]
}
)
}))
describe('registerIpcHandlers window controls', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('restricts custom chrome controls to the trusted main window', async () => {
let maximized = false
const listeners = new Map<string, () => void>()
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => maximized),
minimize: vi.fn(),
maximize: vi.fn(() => {
maximized = true
}),
unmaximize: vi.fn(() => {
maximized = false
}),
close: vi.fn(),
on: vi.fn((name: string, listener: () => void) => {
listeners.set(name, listener)
}),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{ claimDueSchedules: vi.fn(() => []) } as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {})
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
electronMocks.handlers.get(ipcChannels.windowMinimize)?.(event)
electronMocks.handlers.get(
ipcChannels.windowToggleMaximize
)?.(event)
listeners.get('maximize')?.()
electronMocks.handlers.get(ipcChannels.windowClose)?.(event)
expect(window.minimize).toHaveBeenCalledOnce()
expect(window.maximize).toHaveBeenCalledOnce()
expect(webContents.send).toHaveBeenCalledWith(
ipcChannels.windowMaximizedChanged,
true
)
expect(window.close).toHaveBeenCalledOnce()
expect(() =>
electronMocks.handlers
.get(ipcChannels.windowIsMaximized)
?.({
sender: {},
senderFrame: webContents.mainFrame
})
).toThrow('拒绝来自未知窗口的 IPC 请求')
await dispose()
expect(window.removeListener).toHaveBeenCalledWith(
'maximize',
listeners.get('maximize')
)
})
})
describe('registerIpcHandlers workspace files', () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
electronMocks.handlers.clear()
vi.clearAllMocks()
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
it('resolves the project root and validates file requests', async () => {
const rootPath = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-files-'))
temporaryDirectories.push(rootPath)
await writeFile(join(rootPath, 'README.md'), '# GoodBuddy\n')
const projectId = '00000000-0000-4000-8000-000000000101'
const assistantDatabase = {
claimDueSchedules: vi.fn(() => []),
getProject: vi.fn(() => ({ id: projectId, rootPath }))
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {})
)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
const list = await electronMocks.handlers.get(
ipcChannels.workspaceDirectoryList
)?.(event, { projectId, path: '' })
const preview = await electronMocks.handlers.get(
ipcChannels.workspaceFileRead
)?.(event, { projectId, path: 'README.md' })
expect(list).toMatchObject({
entries: [
{ name: 'README.md', path: 'README.md', type: 'file' }
]
})
expect(preview).toMatchObject({
path: 'README.md',
content: '# GoodBuddy\n',
mimeType: 'text/markdown'
})
await expect(
electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.(event, {
projectId,
path: '../outside.txt'
})
).rejects.toThrow('路径必须是工作区内的相对路径')
expect(assistantDatabase.getProject).toHaveBeenCalledWith(projectId)
await dispose()
})
})
describe('registerIpcHandlers token usage', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('returns the database token summary to a trusted renderer', async () => {
const summary = {
totals: {
callCount: 2,
input: 120,
output: 30,
cacheRead: 10,
cacheWrite: 5,
totalTokens: 165
},
records: []
}
const assistantDatabase = {
claimDueSchedules: vi.fn(() => []),
getTokenUsageSummary: vi.fn(() => summary)
}
const webContents = {
mainFrame: {
url: 'file:///goodbuddy/index.html'
},
getURL: vi.fn(() => 'file:///goodbuddy/index.html')
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
assistantDatabase as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {})
)
const handler = electronMocks.handlers.get(
ipcChannels.tokenUsageSummary
)
expect(handler).toBeDefined()
expect(
handler?.({
sender: webContents,
senderFrame: webContents.mainFrame
})
).toBe(summary)
expect(assistantDatabase.getTokenUsageSummary).toHaveBeenCalledOnce()
await dispose()
})
})
describe('registerIpcHandlers agent terminal state', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
function createHarness(
runtime: Record<string, unknown>,
onBeforeClearLocalData?: () => Promise<void>,
toolApproval: 'always' | 'policy' = 'always',
subagentService?: Record<string, unknown>,
smartRoutingEnabled = false
) {
const assistantDatabase = {
claimDueSchedules: vi.fn(() => []),
createTask: vi.fn(),
appendTaskEvent: vi.fn(),
updateTaskStatus: vi.fn(),
createTextArtifact: vi.fn(),
upsertModelUsageCall: vi.fn(),
clearAssistantData: vi.fn(),
listExperts: vi.fn<() => Array<Record<string, unknown>>>(() => []),
getExpert: vi.fn()
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => true),
on: vi.fn(),
removeListener: vi.fn()
}
const contextManager = {
enrichRequest: vi.fn((request) => request),
clear: vi.fn()
}
const approvalBroker = {
request: vi.fn(),
respond: vi.fn(),
clear: vi.fn()
}
const dispose = registerIpcHandlers(
window as never,
runtime as never,
'CommandOrControl+Shift+Space',
{
getResolvedSettings: vi.fn(async () => ({
toolApproval,
subagentSmartRoutingEnabled: smartRoutingEnabled
}))
} as never,
{} as never,
contextManager as never,
{} as never,
assistantDatabase as never,
approvalBroker as never,
{} as never,
vi.fn(async () => {}),
onBeforeClearLocalData,
undefined,
subagentService as never
)
return {
approvalBroker,
assistantDatabase,
contextManager,
dispose,
clearHandler: electronMocks.handlers.get(
ipcChannels.appClearLocalData
),
handler: electronMocks.handlers.get(ipcChannels.agentRun),
cancelHandler: electronMocks.handlers.get(ipcChannels.agentCancel),
webContents
}
}
const trustedEvent = (webContents: {
mainFrame: { url: string }
}) => ({
sender: webContents,
senderFrame: webContents.mainFrame
})
it('aborts active work and clears browser sessions before assistant data', async () => {
const lifecycle: string[] = []
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
_request: unknown,
signal: AbortSignal
): AsyncGenerator<never, void, void> {
markStarted()
await new Promise<void>((_resolve, reject) => {
signal.addEventListener(
'abort',
() => {
lifecycle.push('aborted')
reject(signal.reason)
},
{ once: true }
)
})
yield undefined as never
}
}
const harness = createHarness(runtime, async () => {
lifecycle.push('browser-cleared')
})
vi.mocked(
harness.assistantDatabase.clearAssistantData
).mockImplementation(() => {
lifecycle.push('assistant-cleared')
})
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-clear',
prompt: 'keep running',
workMode: 'execute'
})
await started
await harness.clearHandler?.(trustedEvent(harness.webContents))
expect(lifecycle).toEqual([
'aborted',
'browser-cleared',
'assistant-cleared'
])
await harness.dispose()
})
it('marks a request failed when a tool fails before runtime done', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'tool',
callId: 'call-1',
name: 'write',
state: 'failed',
summary: 'OpenCode 工具:write',
error: 'write path denied'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'write a file',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'failed',
'write 工具执行失败:write path denied'
)
)
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(requestId, 'completed')
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({
requestId,
type: 'error',
status: 'failed',
message: 'write 工具执行失败:write path denied'
})
)
await harness.dispose()
})
it('allows a completed request after a recoverable tool failure', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'tool',
callId: 'call-recoverable',
name: '浏览器输入',
state: 'recoverable',
summary: '直连模型工具需要刷新后重试:浏览器输入'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'retry browser input',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(
requestId,
'failed',
expect.any(String)
)
await harness.dispose()
})
it.each(['opencode', 'continue'] as const)(
'normalizes interactive %s requests to Execute without GoodBuddy approval',
async (runtimeId) => {
let received:
| {
request: { workMode?: string }
authorize: unknown
}
| undefined
const runtime = {
runtimeId,
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
request: { requestId: string; workMode?: string },
_signal: AbortSignal,
authorize: unknown
) {
received = { request, authorize }
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'run the task',
workMode: 'ask'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(received?.request.workMode).toBe('execute')
expect(received?.authorize).toBeUndefined()
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(
harness.assistantDatabase.createTask
).toHaveBeenCalledWith(
expect.objectContaining({ id: requestId, workMode: 'execute' })
)
await harness.dispose()
}
)
it.each(['model', 'opencode'] as const)(
'normalizes legacy interactive Plan requests to Ask for %s',
async (runtimeId) => {
let receivedRequest:
| { requestId: string; prompt: string; workMode?: string }
| undefined
const runtime = {
runtimeId,
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: {
requestId: string
prompt: string
workMode?: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'draft a plan',
workMode: 'plan'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(receivedRequest?.workMode).toBe('ask')
expect(receivedRequest?.prompt).toContain('Work mode: Ask.')
expect(receivedRequest?.prompt).not.toContain('Work mode: Plan.')
expect(
harness.assistantDatabase.createTask
).toHaveBeenCalledWith(
expect.objectContaining({ id: requestId, workMode: 'ask' })
)
await harness.dispose()
}
)
it('routes eligible Ask requests through the persisted smart expert service and publishes child events', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
run: vi.fn()
}
const childTaskId = '00000000-0000-4000-8000-000000000099'
const expert = {
id: '00000000-0000-4000-8000-000000000001',
name: '研究专家',
description: '',
systemInstructions: 'Analyze evidence.',
routingKeywords: ['资料分析'],
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
const subagentService = {
run: vi.fn(async (input: {
parentRequest: { requestId: string }
onEvent: (event: Record<string, unknown>) => void
}) => {
for (const state of ['queued', 'running', 'completed']) {
input.onEvent({
requestId: input.parentRequest.requestId,
type: 'subagent',
childTaskId,
expertId: expert.id,
expertName: expert.name,
routingMode: 'smart',
state
})
}
return { childTaskId, output: '专家结果' }
}),
cancelAll: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
subagentService,
true
)
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
expert
])
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-smart',
prompt: '请做资料分析',
workMode: 'ask',
smartRouting: true
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(runtime.run).not.toHaveBeenCalled()
expect(subagentService.run).toHaveBeenCalledWith(
expect.objectContaining({ expert, routingMode: 'smart' })
)
expect(harness.assistantDatabase.appendTaskEvent).toHaveBeenCalledWith(
requestId,
'subagent',
expect.objectContaining({ childTaskId, state: 'queued' })
)
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({ type: 'subagent', state: 'completed' })
)
await harness.dispose()
})
it.each([
{ workMode: 'ask' as const, persisted: false },
{ workMode: 'execute' as const, persisted: true }
])(
'falls back to the ordinary runtime for ineligible smart routing %#',
async ({ workMode, persisted }) => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: { requestId: string }) {
yield { requestId: request.requestId, type: 'done' }
}
}
const run = vi.spyOn(runtime, 'run')
const subagentService = {
run: vi.fn(),
cancelAll: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
subagentService,
persisted
)
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
{
id: '00000000-0000-4000-8000-000000000001',
name: '研究专家',
description: '',
systemInstructions: 'Analyze.',
routingKeywords: ['资料分析'],
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
])
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-fallback',
prompt: '请做资料分析',
workMode,
smartRouting: true
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'completed'
)
)
expect(run).toHaveBeenCalledOnce()
expect(subagentService.run).not.toHaveBeenCalled()
await harness.dispose()
}
)
it('does not fall back to the ordinary runtime after smart subagent cancellation', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
run: vi.fn()
}
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
const subagentService = {
run: vi.fn((input: { signal: AbortSignal }) => {
markStarted()
return new Promise((_resolve, reject) => {
input.signal.addEventListener(
'abort',
() => reject(input.signal.reason),
{ once: true }
)
})
}),
cancelAll: vi.fn(),
dispose: vi.fn(async () => undefined)
}
const harness = createHarness(
runtime,
undefined,
'always',
subagentService,
true
)
vi.mocked(harness.assistantDatabase.listExperts).mockReturnValue([
{
id: '00000000-0000-4000-8000-000000000001',
name: '研究专家',
description: '',
systemInstructions: 'Analyze.',
routingKeywords: ['资料分析'],
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
])
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-cancel-smart',
prompt: '请做资料分析',
workMode: 'ask',
smartRouting: true
})
await started
harness.cancelHandler?.(trustedEvent(harness.webContents), requestId)
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'cancelled',
'请求已取消'
)
)
expect(runtime.run).not.toHaveBeenCalled()
await harness.dispose()
})
it('rejects Execute before creating a task on an unsupported runtime', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: false,
getStatus: vi.fn(),
dispose: vi.fn(),
run: vi.fn()
}
const harness = createHarness(runtime)
expect(() =>
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'write a file',
workMode: 'execute'
})
).toThrow('当前 Runtime 不支持工具执行')
expect(harness.assistantDatabase.createTask).not.toHaveBeenCalled()
await harness.dispose()
})
it('bridges channel requests to read-only delegation tasks without approval', async () => {
let received:
| {
request: {
requestId: string
conversationId: string
prompt: string
workMode: string
}
authorize?: (request: {
scopeKey: string
title: string
description: string
}) => Promise<string>
}
| undefined
const runtime = {
capability: 'chat',
async *run(
request: {
requestId: string
conversationId: string
prompt: string
workMode: string
},
_signal: AbortSignal,
authorize?: (request: {
scopeKey: string
title: string
description: string
}) => Promise<string>
) {
received = { request, authorize }
yield {
requestId: request.requestId,
type: 'text',
delta: '只读结果'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const executor = channelMocks.executor
if (!executor) {
throw new Error('Expected channel executor')
}
await expect(
executor(
{
channel: 'wecom',
eventId: 'event-1',
senderId: 'user-1',
conversationId: 'conversation-1',
conversationType: 'direct',
text: '请制定只读计划',
mentioned: false,
workMode: 'plan'
},
new AbortController().signal
)
).resolves.toEqual({
status: 'completed',
output: '只读结果'
})
expect(received?.request).toMatchObject({
workMode: 'plan',
prompt: expect.stringContaining('请制定只读计划')
})
await expect(
received?.authorize?.({
scopeKey: 'model:builtin:workspace_read_text',
title: '读取文件',
description: '不应申请批准'
})
).resolves.toBe('deny')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.assistantDatabase.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: '企业微信远程请求',
instructions: '请制定只读计划',
workMode: 'plan',
origin: 'delegation'
})
)
await harness.dispose()
})
it('stops channels before clearing other IPC resources', async () => {
const order: string[] = []
channelMocks.stop.mockImplementationOnce(async () => {
order.push('channel-stop')
})
const harness = createHarness({
capability: 'chat',
run: vi.fn()
})
harness.contextManager.clear.mockImplementation(() => {
order.push('context-clear')
})
await harness.dispose()
expect(order).toEqual(['channel-stop', 'context-clear'])
})
it('authorizes direct-model Execute tools without approval events or broker prompts', async () => {
let receivedAuthorize:
| ((
request: {
scopeKey: string
title: string
description: string
}
) => Promise<string>)
| undefined
let decision: string | undefined
const runtime = {
runtimeId: 'model',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
request: { requestId: string },
_signal: AbortSignal,
authorize: typeof receivedAuthorize
) {
receivedAuthorize = authorize
decision = await authorize?.({
scopeKey: 'model:builtin:workspace_read_text',
title: '允许读取工作区文本?',
description: '读取 README.md'
})
yield {
requestId: request.requestId,
type: 'tool',
callId: 'call-1',
name: '读取工作区文本',
state: 'completed',
summary: '直连模型工具已完成'
}
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: '读取文件',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(receivedAuthorize).toEqual(expect.any(Function))
expect(decision).toBe('once')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(
harness.assistantDatabase.updateTaskStatus
).not.toHaveBeenCalledWith(requestId, 'waiting_approval')
expect(harness.webContents.send).not.toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({ type: 'approval' })
)
await harness.dispose()
})
it('denies direct-model Execute tools when the deny-all policy is selected', async () => {
let decision: string | undefined
const runtime = {
runtimeId: 'model',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(
request: { requestId: string },
_signal: AbortSignal,
authorize: (
request: {
scopeKey: string
title: string
description: string
}
) => Promise<string>
) {
decision = await authorize({
scopeKey: 'model:builtin:workspace_read_text',
title: '允许读取工作区文本?',
description: '读取 README.md'
})
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime, undefined, 'policy')
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: '读取文件',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(decision).toBe('deny')
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
expect(harness.webContents.send).not.toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({ type: 'approval' })
)
await harness.dispose()
})
it('redacts runtime errors before persistence and renderer delivery', async () => {
const runtime = {
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: false,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run() {
yield* []
throw new Error(
'gateway failed Authorization: Bearer secret-token'
)
}
}
const harness = createHarness(runtime)
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'ask',
workMode: 'ask'
})
await vi.waitFor(() =>
expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(
requestId,
'failed',
'gateway failed Authorization: [REDACTED]'
)
)
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.agentEvent,
expect.objectContaining({
message: 'gateway failed Authorization: [REDACTED]'
})
)
await harness.dispose()
})
})