diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 6fd9802a..09a111fb 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -12083,16 +12083,26 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) - it('routes eligible requests through the persisted smart expert service and publishes child events', async () => { + 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(), - run: 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 childTaskId = '00000000-0000-4000-8000-000000000099' const expert = { id: '00000000-0000-4000-8000-000000000001', name: '研究专家', @@ -12104,69 +12114,49 @@ describe('registerIpcHandlers agent terminal state', () => { updatedAt: '2026-01-01T00:00:00.000Z' } const subagentService = { - run: vi.fn(async (input: { - parentRequest: { requestId: string } - onEvent: (event: Record) => 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: '专家结果' } - }), + 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 - ]) + 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-smart', + conversationId: 'conversation-role', prompt: '请做资料分析', - smartRouting: true + ...(route === 'manual' ? { expertId: expert.id } : { smartRouting: true }) }) await vi.waitFor(() => - expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( - requestId, - 'completed' - ) + expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed') ) - expect(runtime.run).not.toHaveBeenCalled() - expect(harness.getPolicySettings).toHaveBeenCalledOnce() - expect(harness.getResolvedSettings).not.toHaveBeenCalled() - expect(subagentService.run).toHaveBeenCalledWith( - expect.objectContaining({ expert, routingMode: 'smart' }) - ) - expect(harness.assistantDatabase.appendTaskEvent).toHaveBeenCalledWith( + 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.objectContaining({ childTaskId, state: 'queued' }) + expect.anything() ) expect(harness.webContents.send).toHaveBeenCalledWith( ipcChannels.agentEvent, - expect.objectContaining({ type: 'subagent', state: 'completed' }) + expect.objectContaining({ type: 'text', delta: '角色回答' }) ) await harness.dispose() }) - it.each(['manual', 'team', 'smart'] as const)('binds %s expert writes to each request project instead of the global workspace', async (route) => { + it('binds expert team writes to each request project instead of the global workspace', async () => { const actual = await vi.importActual('./agent/create-runtime') const directory = await mkdtemp(join(tmpdir(), 'expert-projects-')) const globalRoot = join(directory, 'global') @@ -12215,11 +12205,10 @@ describe('registerIpcHandlers agent terminal state', () => { 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'}`, expertId: route === 'manual' ? expert.id : undefined, - teamMode: route === 'team', smartRouting: route === 'smart' }) + 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(route === 'team' ? 2 : 1) + 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]) @@ -12236,9 +12225,7 @@ describe('registerIpcHandlers agent terminal state', () => { } }) - it.each((['manual', 'team', 'smart'] as const).flatMap(route => - (['opencode', 'continue'] as const).map(provider => ({ route, provider })) - ))('uses managed $provider for $route SSH experts with their profile and parent bindings', async ({ route, provider }) => { + 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('./agent/create-runtime') const globalRoot = await mkdtemp(join(tmpdir(), 'expert-ssh-')) const projectId = crypto.randomUUID() @@ -12297,14 +12284,13 @@ describe('registerIpcHandlers agent terminal state', () => { try { const requestId = crypto.randomUUID() await harness.handler!(trustedEvent(harness.webContents), { requestId, conversationId: 'remote-expert', projectId, - prompt: 'write a file', expertId: route === 'manual' ? expert.id : undefined, - teamMode: route === 'team', smartRouting: route === 'smart' }) + prompt: 'write a file', teamMode: true }) await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed')) - expect(factory).toHaveBeenCalledTimes(route === 'team' ? 2 : 1) + expect(factory).toHaveBeenCalledTimes(2) expect(createRemote).toHaveBeenCalledWith({ provider, profileId: expert.modelProfileId }, executionSpace) expect(createLocal).not.toHaveBeenCalled() expect(binding.save).not.toHaveBeenCalled() - expect(fetcher).toHaveBeenCalledTimes(route === 'team' ? 1 : 0) + expect(fetcher).toHaveBeenCalledTimes(1) expect(ordinary.run).not.toHaveBeenCalled() expect(await readdir(globalRoot)).toEqual([]) } finally { @@ -12375,72 +12361,6 @@ describe('registerIpcHandlers agent terminal state', () => { } ) - 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((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, - 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: '请做资料分析', - 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('allows ordinary text requests on runtimes without tool execution', async () => { const runtime = { capability: 'chat', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a08f5b17..7ba58efe 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -282,10 +282,7 @@ import { supervisionKnowledgeCommitRequestSchema } from '../shared/supervision-contracts' import { showDesktopNotificationWhenUnfocused } from './desktop-notification' -import { - SubagentRunError, - type SubagentService -} from './assistant/subagent-service' +import type { SubagentService } from './assistant/subagent-service' import { routeSubagent } from './assistant/subagent-router' import { startEnvironmentChannels @@ -2699,37 +2696,20 @@ export function registerIpcHandlers( yield { requestId: request.requestId, type: 'done' } } - const runSingleExpert = async function* ( - request: AgentExecutionRequest, - expert: Awaited>, - routingMode: 'manual' | 'smart', - signal: AbortSignal, - executionSpace: ExecutionSpaceDescriptor | undefined, - reason?: string - ): AsyncGenerator { - if (!subagentService) { - throw new Error('专家子任务服务不可用') - } - const result = await subagentService.run({ - parentRequest: request, - executionSpace, - expert, - routingMode, - reason, - signal, - onEvent: async (event) => - (await publishSubagentEvent(request.requestId, event)), - onModelUsage: persistModelUsage - }) - if (result.output) { - yield { - requestId: request.requestId, - type: 'text', - delta: result.output - } - } - yield { requestId: request.requestId, type: 'done' } - } + /** + * A selected or smart-routed expert is a role prompt for the conversation's + * own runtime, not a delegated child task: it joins the trusted + * instructions so streaming, tools and the selected runtime stay unchanged. + */ + const expertRolePrompt = ( + expert: Awaited> + ): string => + [ + `Role: act as the specialist "${expert.name}" for this request.`, + expert.systemInstructions + ] + .filter(Boolean) + .join('\n\n') let scheduleQueueTickRunning = false const queueDueSchedules = async (): Promise => { @@ -4192,6 +4172,15 @@ export function registerIpcHandlers( ) } } + // A selected or smart-routed expert is a role prompt for the + // conversation's own runtime, not a delegated child task. + const roleExpert = + executionRequest.teamMode || imageGeneration + ? undefined + : executionRequest.expertId + ? await assistantDatabase.getExpert(executionRequest.expertId) + : smartRoute?.expert + const rolePrompt = roleExpert ? expertRolePrompt(roleExpert) : '' const ordinaryStream = (): AsyncGenerator< RuntimeEvent, void, @@ -4202,66 +4191,40 @@ export function registerIpcHandlers( managedSshOperationAccepted = true markManagedSshAccepted() } + const runtimeRequest: AgentExecutionRequest = rolePrompt + ? { + ...executionRequest, + trustedInstructions: [ + executionRequest.trustedInstructions, + rolePrompt + ] + .filter(Boolean) + .join('\n\n') + } + : executionRequest + // Runtimes without a system channel get the capability and role + // instructions ahead of the prompt. + const promptPrefix = + selectedRuntime.consumesTrustedInstructions !== true + ? [capabilityInstruction, rolePrompt].filter(Boolean).join('\n\n') + : '' return selectedRuntime.run( - capabilityInstruction && selectedRuntime.consumesTrustedInstructions !== true + promptPrefix ? { - ...executionRequest, - prompt: `${capabilityInstruction}\n\n${executionRequest.prompt}` + ...runtimeRequest, + prompt: `${promptPrefix}\n\n${runtimeRequest.prompt}` } - : executionRequest, + : runtimeRequest, controller.signal ) } - const runSmartRoute = async function* (): AsyncGenerator< - RuntimeEvent, - void, - void - > { - if (!smartRoute) { - yield* ordinaryStream() - return - } - try { - yield* runSingleExpert( - executionRequest, - smartRoute.expert, - 'smart', - controller.signal, - requestExecutionSpace, - `匹配 ${smartRoute.matches} 个关键词,得分 ${smartRoute.score}` - ) - } catch (error) { - if (controller.signal.aborted) { - throw error - } - if (error instanceof SubagentRunError && error.output) { - yield { - requestId: request.requestId, - type: 'text', - delta: error.output - } - throw error - } - yield* ordinaryStream() - } - } const eventStream = executionRequest.teamMode ? runExpertTeam( executionRequest, controller.signal, requestExecutionSpace ) - : executionRequest.expertId && !imageGeneration - ? runSingleExpert( - executionRequest, - await assistantDatabase.getExpert( - executionRequest.expertId - ), - 'manual', - controller.signal, - requestExecutionSpace - ) - : runSmartRoute() + : ordinaryStream() for await (const agentEvent of splitTaggedReasoning(eventStream)) { if (agentEvent.type === 'done' || agentEvent.type === 'error') activeRequestLease.settling = true const provenance = remoteSemanticProvenance(agentEvent) diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 0931392e..c1b67cf5 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -3035,7 +3035,7 @@ describe('SettingsPanel runtime files', () => { '自动选择 1 位专家' ) expect(screen.getByText(/未显式选择专家或团队时/)).toHaveTextContent( - '子专家使用默认文本模型和已启用工具' + '专家提示词作为角色提示词,由当前会话的 Runtime 直接执行' ) fireEvent.click(smartRouting) diff --git a/src/renderer/src/i18n/locales/en-US/settings.ts b/src/renderer/src/i18n/locales/en-US/settings.ts index 61c7864c..8f23dac6 100644 --- a/src/renderer/src/i18n/locales/en-US/settings.ts +++ b/src/renderer/src/i18n/locales/en-US/settings.ts @@ -867,7 +867,7 @@ export const settings = { 'Automatically choose the expert role that best matches the question', enabled: 'Enable Smart Subagent routing', help: - 'Off by default. When no expert or team is explicitly selected, GoodBuddy chooses one expert. The Subagent uses the default text model and enabled tools.' + 'Off by default. When no expert or team is explicitly selected, GoodBuddy chooses one expert. Its prompt is applied as a role prompt on the conversation\'s current Runtime.' } }, appearance: { diff --git a/src/renderer/src/i18n/locales/zh-CN/settings.ts b/src/renderer/src/i18n/locales/zh-CN/settings.ts index 09373942..861dc0da 100644 --- a/src/renderer/src/i18n/locales/zh-CN/settings.ts +++ b/src/renderer/src/i18n/locales/zh-CN/settings.ts @@ -802,7 +802,7 @@ export const settings = { description: '按问题内容自动选择最匹配的专家角色', enabled: '启用 Subagent 智能路由', help: - '默认关闭。未显式选择专家或团队时,自动选择 1 位专家;子专家使用默认文本模型和已启用工具。' + '默认关闭。未显式选择专家或团队时,自动选择 1 位专家;专家提示词作为角色提示词,由当前会话的 Runtime 直接执行。' } }, appearance: {