Files
goodbuddy/src/main/assistant/subagent-service.test.ts
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

391 lines
18 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { randomUUID } from 'node:crypto'
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type { AssistantExpert } from '../../shared/assistant-contracts'
import type { SubagentEvent } from '../../shared/contracts'
import type {
AgentExecutionRequest,
AgentRuntime
} from '../agent/runtime'
import { SubagentService, createSubagentRuntime } from './subagent-service'
import { SubagentScheduler } from './subagent-scheduler'
import { ExecutionSpaceResolver } from '../execution-space'
import { browserTabIdSchema, defaultRuntimeSettings } from '../../shared/contracts'
import { MainImageToolSession } from '../remote-agent/main-image-tool-session'
import type { RuntimeProtocolBinaryChannel, RuntimeProtocolBinaryFrame } from '../remote-agent/protocol-remote-runtime-channel'
import type { ImageToolBinding } from '../agent/image-tool-binding'
const expert: AssistantExpert = {
id: '00000000-0000-4000-8000-000000000001',
name: '研究专家',
description: '',
systemInstructions: 'Separate evidence from assumptions.',
routingKeywords: ['研究'],
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z'
}
const parentRequest: AgentExecutionRequest = {
requestId: '00000000-0000-4000-8000-000000000010',
conversationId: 'conversation',
prompt: '研究这份材料'
}
function database() {
return {
createTask: vi.fn(() => ({})),
updateTaskStatus: vi.fn(),
appendTaskEvent: vi.fn()
}
}
describe('SubagentService', () => {
it('waits for asynchronous creation and cancellation persistence during shutdown', async () => {
let created!: () => void
let saved!: () => void
const db = {
createTask: vi.fn(() => new Promise<void>(resolve => { created = resolve })),
updateTaskStatus: vi.fn(() => new Promise<void>(resolve => { saved = resolve })),
appendTaskEvent: vi.fn()
}
const createRuntime = vi.fn()
const service = new SubagentService(createRuntime, db as never)
const run = service.run({ parentRequest, expert, routingMode: 'manual', signal: new AbortController().signal, onEvent: vi.fn() })
const rejected = expect(run).rejects.toThrow()
const closed = vi.fn()
const closing = service.dispose().then(closed)
await Promise.resolve()
expect(closed).not.toHaveBeenCalled()
created()
await vi.waitFor(() => expect(db.updateTaskStatus).toHaveBeenCalledWith(expect.any(String), 'cancelled', expect.any(String)))
expect(createRuntime).not.toHaveBeenCalled()
expect(closed).not.toHaveBeenCalled()
saved()
await rejected
await closing
})
it('reports rejected persistence to the caller and shutdown', async () => {
const failure = new Error('Task storage unavailable')
const db = { ...database(), createTask: async () => { throw failure } }
const createRuntime = vi.fn()
const service = new SubagentService(createRuntime, db as never)
await expect(service.run({ parentRequest, expert, routingMode: 'manual', signal: new AbortController().signal, onEvent: vi.fn() })).rejects.toBe(failure)
expect(createRuntime).not.toHaveBeenCalled()
await expect(service.dispose()).rejects.toBe(failure)
})
it.each(['opencode', 'continue'] as const)('saves SSH expert images through the %s Host adapter, never the Desktop save binding', async provider => {
// Load the Agent entry dynamically: Main and Agent are separate TS projects.
const { AgentImageToolMcp } = await import('../../agent-daemon/' + 'image-tool-mcp')
const root = await mkdtemp(join(tmpdir(), 'expert-image-save-'))
const desktop = join(root, 'desktop')
const hostPath = join(root, 'host', 'nested', 'fixture.png')
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64')
const artifactId = randomUUID()
const binding: ImageToolBinding = {
context: { conversationId: parentRequest.conversationId, requestId: parentRequest.requestId, messageId: randomUUID() },
describe: vi.fn(async () => undefined),
describeSave: vi.fn(async () => 'Save the owned image fixture'),
call: vi.fn(async () => { throw new Error('Generation must not be called') }),
save: vi.fn(async () => { throw new Error('Desktop writer must not be called') }),
readForSave: vi.fn(async () => png)
}
const settings = { ...defaultRuntimeSettings, workspacePath: desktop, defaultModelProfileId: randomUUID(),
modelProfiles: [{ id: randomUUID(), name: 'Expert', baseUrl: 'https://model.invalid/v1', modelName: 'expert',
protocol: 'openai-chat-completions' as const, authentication: 'none' as const }] }
const executionSpace = { kind: 'ssh' as const, hostId: randomUUID(), remoteRootPath: '/project',
cacheIdentity: 'remote-project', routeIdentity: 'remote-project', workspaceAccess: {} as never }
const createLocal = vi.fn(async () => { throw new Error('Local runtime must not be created') })
const remoteRuntime = {
async *run(request: AgentExecutionRequest) {
expect(request.prompt).toContain(expert.systemInstructions)
expect(request.imageToolBinding).toBe(binding)
const queue: RuntimeProtocolBinaryFrame[] = []
let receiver: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined
let rejectReceive: ((error: Error) => void) | undefined
const listeners = new Set<() => void>()
let closed = false
const channel: RuntimeProtocolBinaryChannel = {
channelId: randomUUID(), channelEpoch: '1',
send: async payload => adapter.onReply(payload),
receive: async () => queue.shift() ?? new Promise((resolve, reject) => { receiver = resolve; rejectReceive = reject }),
close: () => { if (closed) return; closed = true; rejectReceive?.(new Error('closed')); for (const listener of listeners) listener() },
onClose: listener => { listeners.add(listener); return () => { listeners.delete(listener) } }
}
const main = new MainImageToolSession(channel, request.imageToolBinding)
const adapter = new AgentImageToolMcp({ channelId: channel.channelId, channelEpoch: '1', saveDescription: await binding.describeSave!() }, async (payload: Uint8Array) => {
const frame = { payload, sequence: '1', consume: async () => {} }
if (receiver) { const resolve = receiver; receiver = undefined; resolve(frame) } else queue.push(frame)
})
const client = new Client({ name: 'expert-image-test', version: '1' })
try {
await adapter.start()
await client.connect(new StreamableHTTPClientTransport(new URL(adapter.url!)))
const save = (path: string, overwrite = false) => client.callTool({ name: 'save_image', arguments: { artifactId, path, overwrite } })
expect((await save('relative.png')).isError).toBe(true)
expect((await save(hostPath)).isError).not.toBe(true)
expect(await readFile(hostPath)).toEqual(png)
expect((await save(hostPath)).isError).toBe(true)
expect(await readFile(hostPath)).toEqual(png)
expect((await save(hostPath, true)).isError).not.toBe(true)
yield { requestId: request.requestId, type: 'done' } as const
} finally {
main.close(); adapter.close(); await client.close()
}
},
releaseConversation: vi.fn(), dispose: vi.fn()
} as unknown as AgentRuntime
const createRemote = vi.fn(async () => remoteRuntime)
const service = new SubagentService(input => createSubagentRuntime(input, settings, createLocal, createRemote), database() as never)
try {
await service.run({ parentRequest: { ...parentRequest, projectId: 'project', runtimeSelection: { provider }, imageToolBinding: binding },
executionSpace, expert: { ...expert, modelProfileId: settings.modelProfiles[0]!.id }, routingMode: 'manual',
signal: new AbortController().signal, onEvent: vi.fn() })
expect(createRemote).toHaveBeenCalledWith({ provider, profileId: settings.modelProfiles[0]!.id }, executionSpace)
expect(createLocal).not.toHaveBeenCalled()
expect(binding.save).not.toHaveBeenCalled()
expect(binding.call).not.toHaveBeenCalled()
expect(binding.readForSave).toHaveBeenCalledWith(artifactId, 'image/png')
expect(await readdir(root)).toEqual(['host'])
} finally { await service.dispose(); await rm(root, { recursive: true, force: true }) }
})
it('uses the dedicated tool-free runtime for synthesis instead of the specialist runtime', async () => {
const specialist = { run: vi.fn(), dispose: vi.fn() } as unknown as AgentRuntime
let executionRequest: AgentExecutionRequest | undefined
const synthesis = {
run: async function* (request: AgentExecutionRequest) {
executionRequest = request
yield { requestId: request.requestId, type: 'text', delta: 'Summary' } as const
yield { requestId: request.requestId, type: 'done' } as const
},
releaseConversation: vi.fn(),
dispose: vi.fn()
} as unknown as AgentRuntime
const service = new SubagentService(async () => specialist, database() as never)
await expect(service.synthesize(parentRequest, 'Combine results', new AbortController().signal, synthesis))
.resolves.toBe('Summary')
expect(specialist.run).not.toHaveBeenCalled()
expect(executionRequest).not.toHaveProperty('workMode')
expect(synthesis.releaseConversation).toHaveBeenCalledWith(`subagent-synthesis:${parentRequest.requestId}`)
await service.dispose()
})
it('creates a linked child task and puts expert instructions in system context', async () => {
let executionRequest: AgentExecutionRequest | undefined
const expertOutput = '结果'.repeat(35_001)
const runtime = {
consumesTrustedInstructions: true,
run: async function* (request: AgentExecutionRequest) {
executionRequest = request
yield {
requestId: request.requestId,
type: 'text',
delta: expertOutput
} as const
yield { requestId: request.requestId, type: 'done' } as const
},
releaseConversation: vi.fn(async () => undefined),
dispose: vi.fn(async () => undefined)
} as unknown as AgentRuntime
const db = database()
const service = new SubagentService(
async () => runtime,
db as never,
new SubagentScheduler({ timeoutMs: 1_000 })
)
const events: SubagentEvent[] = []
const scopedRequest: AgentExecutionRequest = {
...parentRequest,
projectId: 'project',
executionWorkspace: '/project',
knowledgeCapabilityToken: 'parent-grant',
browserTabId: browserTabIdSchema.parse('00000000-0000-4000-8000-000000000201'),
imageToolBinding: { context: {} as never, describe: vi.fn(), call: vi.fn() },
storyGraphBinding: { kind: 'test-binding' } as never,
images: [{ name: 'source.png', mediaType: 'image/png', data: 'aW1hZ2U=' }],
trustedInstructions: 'Parent capability instructions'
}
const result = await service.run({
parentRequest: scopedRequest,
executionSpace: new ExecutionSpaceResolver().resolveLocal('/project'),
expert,
routingMode: 'smart',
signal: new AbortController().signal,
onEvent: (event) => { events.push(event) }
})
expect(result.output).toBe(expertOutput)
expect(result.output.length).toBeGreaterThan(60_000)
expect(db.createTask).toHaveBeenCalledWith(
expect.objectContaining({
parentTaskId: parentRequest.requestId,
expertId: expert.id,
routingMode: 'smart',
status: 'queued'
})
)
expect(executionRequest?.prompt).toBe(parentRequest.prompt)
expect(executionRequest).toMatchObject({ ...scopedRequest,
requestId: result.childTaskId, conversationId: expect.stringContaining('subagent:'),
trustedInstructions: expect.stringContaining(scopedRequest.trustedInstructions!) })
expect(executionRequest?.imageToolBinding).toBe(scopedRequest.imageToolBinding)
expect(executionRequest?.storyGraphBinding).toBe(scopedRequest.storyGraphBinding)
expect(executionRequest).toHaveProperty('browserConversationId', scopedRequest.conversationId)
expect(executionRequest?.trustedInstructions).toContain(
expert.systemInstructions
)
expect(events.map((event) => event.state)).toEqual([
'queued',
'running',
'completed'
])
expect(events.at(-1)).toMatchObject({
state: 'completed',
output: expertOutput
})
await service.dispose()
})
it('does not construct a global runtime when a project execution space is missing', async () => {
const createRuntime = vi.fn()
const service = new SubagentService(createRuntime, database() as never)
await expect(service.run({ parentRequest: { ...parentRequest, projectId: 'project' }, expert,
routingMode: 'smart', signal: new AbortController().signal, onEvent: vi.fn() }))
.rejects.toThrow('Expert project execution space is unavailable')
expect(createRuntime).not.toHaveBeenCalled()
await service.dispose()
})
it('disposes a child created after cancellation without starting its model', async () => {
let resolveRuntime!: (runtime: AgentRuntime) => void
const createRuntime = vi.fn(() => new Promise<AgentRuntime>(resolve => { resolveRuntime = resolve }))
const runtime = { run: vi.fn(), releaseConversation: vi.fn(), dispose: vi.fn() } as unknown as AgentRuntime
const service = new SubagentService(createRuntime, database() as never)
const controller = new AbortController()
const result = service.run({ parentRequest, expert, routingMode: 'manual', signal: controller.signal, onEvent: vi.fn() })
const rejected = expect(result).rejects.toThrow('Cancelled during creation')
await vi.waitFor(() => expect(createRuntime).toHaveBeenCalledOnce())
controller.abort(new Error('Cancelled during creation'))
resolveRuntime(runtime)
await rejected
await service.dispose()
expect(runtime.run).not.toHaveBeenCalled()
expect(runtime.dispose).toHaveBeenCalledOnce()
})
it('forwards the parent authorizer and records expert tool activity without a mode', async () => {
const events: SubagentEvent[] = []
const authorize = vi.fn(async () => 'once' as const)
let receivedAuthorize: unknown
const runtime = {
run: async function* (
request: AgentExecutionRequest,
signal: AbortSignal,
runtimeAuthorize: unknown
) {
void signal
receivedAuthorize = runtimeAuthorize
yield {
requestId: request.requestId,
type: 'text',
delta: '部分结果'
} as const
yield {
requestId: request.requestId,
type: 'tool',
callId: 'call',
name: 'unsafe',
state: 'running',
summary: 'unsafe'
} as const
yield { requestId: request.requestId, type: 'done' } as const
},
dispose: vi.fn(async () => undefined)
} as unknown as AgentRuntime
const db = database()
const service = new SubagentService(async () => runtime, db as never)
await expect(service.run({
parentRequest,
expert,
routingMode: 'manual',
signal: new AbortController().signal,
onEvent: (event) => { events.push(event) },
authorize
})).resolves.toMatchObject({ output: '部分结果' })
expect(receivedAuthorize).toBe(authorize)
expect(db.createTask).toHaveBeenCalledWith(expect.not.objectContaining({ workMode: expect.anything() }))
expect(db.appendTaskEvent).toHaveBeenCalledWith(
expect.any(String),
'tool',
expect.objectContaining({ name: 'unsafe' })
)
expect(events.at(-1)).toMatchObject({
state: 'completed',
output: '部分结果'
})
await service.dispose()
})
it('passes the expert profile to the per-run factory and disposes each child runtime', async () => {
const calls: string[] = []
const createRuntime = (label: string): AgentRuntime =>
({
run: async function* (request: AgentExecutionRequest) {
calls.push(label)
yield {
requestId: request.requestId,
type: 'text',
delta: label
} as const
yield { requestId: request.requestId, type: 'done' } as const
},
releaseConversation: vi.fn(async () => undefined),
dispose: vi.fn(async () => undefined)
}) as unknown as AgentRuntime
const defaultRuntime = createRuntime('default')
const profileRuntime = createRuntime('profile')
const profileId = '00000000-0000-4000-8000-000000000002'
const service = new SubagentService(
async input => input.expert.modelProfileId === profileId ? profileRuntime : defaultRuntime,
database() as never,
new SubagentScheduler({ timeoutMs: 1_000 })
)
const selected = await service.run({
parentRequest,
expert: { ...expert, modelProfileId: profileId },
routingMode: 'manual',
signal: new AbortController().signal,
onEvent: vi.fn()
})
const fallback = await service.run({
parentRequest: {
...parentRequest,
requestId: '00000000-0000-4000-8000-000000000011'
},
expert: {
...expert,
modelProfileId: '00000000-0000-4000-8000-000000000099'
},
routingMode: 'manual',
signal: new AbortController().signal,
onEvent: vi.fn()
})
expect(selected.output).toBe('profile')
expect(fallback.output).toBe('default')
expect(calls).toEqual(['profile', 'default'])
expect(profileRuntime.dispose).toHaveBeenCalledOnce()
expect(defaultRuntime.dispose).toHaveBeenCalledOnce()
await service.dispose()
})
})