feat: add app management, local inference controls, and task statistics

This commit is contained in:
mesalogo
2026-09-18 08:20:45 +08:00
parent 6dd6297414
commit bbae893c45
103 changed files with 7962 additions and 1490 deletions
+158 -5
View File
@@ -12,6 +12,7 @@ import { join } from 'node:path'
import { ipcChannels } from '../shared/ipc-channels'
import type {
AssistantProject,
AssistantSchedule,
ConversationSnapshot,
ConversationQueueItem
} from '../shared/assistant-contracts'
@@ -21,7 +22,8 @@ import {
runtimeSettingsInputSchema,
type AgentEvent,
type BrowserLiveState,
type BrowserTabId
type BrowserTabId,
type ConversationQueueDispatch
} from '../shared/contracts'
import type {
SshHostAgentConnectionStatus
@@ -1056,6 +1058,8 @@ describe('registerIpcHandlers update source routing', () => {
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,
@@ -1076,10 +1080,14 @@ describe('registerIpcHandlers update source routing', () => {
undefined,
undefined,
undefined,
{ get: getApplicationSettings } as never,
{ get: getApplicationSettings, onChanged } as never,
versionChecker as never
)
const changedSettings = { updateSource: 'github' }
onChanged.mock.calls[0]![0](changedSettings)
expect(webContents.send).toHaveBeenCalledWith(ipcChannels.applicationSettingsChanged, changedSettings)
await expect(
electronMocks.handlers.get(ipcChannels.versionCheck)?.(event)
).resolves.toEqual(result)
@@ -1111,6 +1119,7 @@ describe('registerIpcHandlers update source routing', () => {
)
await dispose()
expect(unsubscribe).toHaveBeenCalledOnce()
})
})
@@ -1177,6 +1186,7 @@ describe('registerIpcHandlers model download source routing', () => {
snapshot: vi.fn(async () => ocrSnapshot)
}
const applicationSettingsStore = {
onChanged: vi.fn(),
get: vi.fn(async () => ({
modelDownloadSource: 'hugging-face'
}))
@@ -1781,7 +1791,8 @@ describe('registerIpcHandlers SSH hosts', () => {
undefined, // subagentService
undefined, // channelSettingsStore
{
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
get: vi.fn(async () => ({ remoteProjectsEnabled: true })),
onChanged: vi.fn()
} as never, // applicationSettingsStore
undefined, // versionChecker
undefined, // speechModelManager
@@ -2212,6 +2223,7 @@ describe('registerIpcHandlers SSH hosts', () => {
updatedAt: '2026-08-01T00:00:00.000Z'
}
const applicationSettingsStore = {
onChanged: vi.fn(),
get: vi.fn(async () => ({ remoteProjectsEnabled: false }))
}
const sshHostService = {
@@ -4235,6 +4247,7 @@ describe('registerIpcHandlers workspace files', () => {
undefined,
undefined,
{
onChanged: vi.fn(),
get: vi.fn(async () => {
throw new Error('settings unavailable')
})
@@ -4323,6 +4336,7 @@ describe('registerIpcHandlers workspace files', () => {
undefined,
undefined,
{
onChanged: vi.fn(),
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
} as never
)
@@ -4363,6 +4377,42 @@ describe('registerIpcHandlers token usage', () => {
vi.clearAllMocks()
})
it('validates execution statistics scope and sender before reading evidence', async () => {
const summary = { durationMs: 5000, requestCount: 2, incompleteRequestCount: 1, activeRequestCount: 0, asOf: 10000, taskDurations: [{ id: 'task', durationMs: 5000, incompleteRequestCount: 1 }] }
const assistantDatabase = {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getExecutionStats: vi.fn(() => summary)
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn()
}
const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() }
const dispose = registerIpcHandlers(
window as never, { capability: 'text' } as never,
'CommandOrControl+Shift+Space', {} as never, {} as never,
{ clear: vi.fn() } as never, {} as never, assistantDatabase as never,
{ clear: vi.fn() } as never, {} as never, vi.fn(async () => {})
)
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(handler(event, scope)).toBe(summary)
expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope, new Set())
}
expect(() => handler(event, {})).toThrow()
expect(() => handler(event, { projectId: id, conversationId: id })).toThrow()
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow()
expect(assistantDatabase.getExecutionStats).toHaveBeenCalledTimes(2)
} finally {
await dispose()
}
})
it('returns the database token summary to a trusted renderer', async () => {
const summary = {
totals: {
@@ -5273,7 +5323,8 @@ describe('registerIpcHandlers agent terminal state', () => {
subagentService as never,
undefined,
{
get: getApplicationSettings
get: getApplicationSettings,
onChanged: vi.fn()
} as never,
undefined,
undefined,
@@ -5516,7 +5567,7 @@ describe('registerIpcHandlers agent terminal state', () => {
title: '每周汇总',
prompt: '汇总本周进展',
recurrence: 'weekly' as const,
nextRunAt: '2026-08-21T09:00:00.000Z'
nextRunAt: new Date(Date.now() + 60_000).toISOString()
}
await electronMocks.handlers.get(
@@ -5532,6 +5583,68 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it.each(['completed', 'failed'] as const)('creates an immediate task into the production queue and dispatches exactly one ordinary run (%s)', async (status) => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const conversationId = crypto.randomUUID()
database.saveLocalConversations([{
header: { id: conversationId, title: 'Immediate', updatedAt: Date.now(), workMode: 'ask' },
messages: []
}])
const run = vi.fn(async function* (request: AgentExecutionRequest) {
if (status === 'failed') {
yield { type: 'error' as const, requestId: request.requestId, message: 'Runtime failed' }
return
}
yield { type: 'text' as const, requestId: request.requestId, delta: 'Once' }
yield { type: 'done' as const, requestId: request.requestId }
})
const harness = createHarness(
{ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run },
undefined, 'always', undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database
)
const event = trustedEvent(harness.webContents)
try {
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
const create = electronMocks.handlers.get(ipcChannels.schedulesCreate)!
const input = {
conversationId, title: 'Immediate', prompt: 'Run once', recurrence: 'once',
nextRunAt: '2020-01-01T00:00:00.000Z'
}
expect(() => create(event, input)).toThrow('首次运行时间必须晚于当前时间')
expect(database.listSchedules()).toEqual([])
const schedule = await create(event, { ...input, runImmediately: true }) as AssistantSchedule
expect(schedule.enabled).toBe(false)
const dispatches = () => harness.webContents.send.mock.calls.filter(
([channel]) => channel === ipcChannels.conversationQueueDispatch
)
expect(dispatches()).toHaveLength(1)
const dispatch = dispatches()[0]![1] as ConversationQueueDispatch
expect(dispatch).toMatchObject({ scheduled: true, item: { taskId: schedule.taskId } })
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
await harness.handler!(event, {
requestId: dispatch.item.id,
conversationId,
queueItemId: dispatch.item.id,
prompt: input.prompt,
workMode: 'ask',
currentUserMessageId: crypto.randomUUID(),
currentAssistantMessageId: crypto.randomUUID()
})
await vi.waitFor(() => expect(database.getTask(schedule.taskId).status).toBe(status))
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
expect(dispatches()).toHaveLength(1)
expect(run).toHaveBeenCalledOnce()
expect(database.listConversationQueueItems()).toEqual([])
expect(database.listSchedules()).toHaveLength(1)
} finally {
await harness.dispose()
database.close()
}
})
it('dispatches scheduled messages through ordinary agent runs with current history and tools', async () => {
const fullOutput = `${'x'.repeat(1_000_001)} scheduled tail`
const taskId = '00000000-0000-4000-8000-000000000701'
@@ -7814,6 +7927,46 @@ describe('registerIpcHandlers agent terminal state', () => {
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, 'always', undefined, false, undefined, {
database: { listKnowledgeBases: vi.fn(() => [{ id: libraryId, name: 'Knowledge' }]) }, retrieveMany
}, gateway)
const request = { prompt: 'use saved knowledge', workMode: 'ask', knowledgeLibraryIds: [libraryId], knowledgeRetrievalMode: 'always' }
try {
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000021', conversationId: 'in-flight' })
await vi.waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(retrieveMany).toHaveBeenCalledOnce()
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: false, magicNotesEnabled: false })
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000022', conversationId: 'disabled' })
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2))
expect(run.mock.calls[0]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(run.mock.calls[1]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(retrieveMany).toHaveBeenCalledTimes(2)
expect(gateway.grant).toHaveBeenCalledTimes(2)
expect(harness.assistantDatabase.updateTaskStatus).not.toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'cancelled')
finish!()
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'completed'))
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: true, magicNotesEnabled: false })
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000023', conversationId: 'restored' })
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(3))
expect(run.mock.calls[2]![0].knowledgeLibraryIds).toEqual([libraryId])
expect(retrieveMany).toHaveBeenCalledTimes(3)
expect(request.knowledgeLibraryIds).toEqual([libraryId])
} finally {
finish!()
await harness.dispose()
}
})
it('grants read-only Magic Notes tools in Ask and write tools in Execute', async () => {
const runtime = {
runtimeId: 'model',