From 28b15907495bb4ff41ab3d3615d2ee6a2709bcf3 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Wed, 19 Aug 2026 01:32:19 +0800 Subject: [PATCH] fix: show expert outputs before synthesis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel expert runs previously exposed only child status, while the synthesized answer appeared above the expert cards. Each expert now retains and exposes its complete output, the cards can be expanded, and the final synthesis is rendered beneath them and persisted with the conversation. Release note: 多专家并行分析现在可展开查看每位专家的完整输出,并在其下方显示总 Agent 的综合结果。 --- FEATURES.md | 2 +- src/main/assistant/subagent-service.test.ts | 38 ++++++-- src/main/assistant/subagent-service.ts | 13 ++- src/renderer/src/App.test.tsx | 33 ++++++- src/renderer/src/App.tsx | 11 +++ src/renderer/src/ChatTimeline.test.tsx | 82 +++++++++++++++- src/renderer/src/ChatTimeline.tsx | 100 ++++++++++++-------- src/renderer/src/i18n/locales/en-US/app.ts | 3 + src/renderer/src/i18n/locales/zh-CN/app.ts | 3 + src/renderer/src/styles.css | 60 ++++++++++-- src/shared/assistant-contracts.ts | 27 ++++++ src/shared/contracts.ts | 1 + 12 files changed, 311 insertions(+), 62 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index ba82ecd..21e12ad 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -24,7 +24,7 @@ - [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。 - [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。 - [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。 -- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。 +- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 - [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。 - [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。 diff --git a/src/main/assistant/subagent-service.test.ts b/src/main/assistant/subagent-service.test.ts index 458dcd2..c6e88b3 100644 --- a/src/main/assistant/subagent-service.test.ts +++ b/src/main/assistant/subagent-service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AssistantExpert } from '../../shared/assistant-contracts' +import type { SubagentEvent } from '../../shared/contracts' import type { AgentExecutionRequest, AgentRuntime @@ -36,10 +37,15 @@ function database() { describe('SubagentService', () => { 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 = { run: async function* (request: AgentExecutionRequest) { executionRequest = request - yield { requestId: request.requestId, type: 'text', delta: '结果' } as const + yield { + requestId: request.requestId, + type: 'text', + delta: expertOutput + } as const yield { requestId: request.requestId, type: 'done' } as const }, releaseConversation: vi.fn(async () => undefined), @@ -51,16 +57,17 @@ describe('SubagentService', () => { db as never, new SubagentScheduler({ timeoutMs: 1_000 }) ) - const events: string[] = [] + const events: SubagentEvent[] = [] const result = await service.run({ parentRequest, expert, routingMode: 'smart', signal: new AbortController().signal, - onEvent: (event) => events.push(event.state) + onEvent: (event) => events.push(event) }) - expect(result.output).toBe('结果') + expect(result.output).toBe(expertOutput) + expect(result.output.length).toBeGreaterThan(60_000) expect(db.createTask).toHaveBeenCalledWith( expect.objectContaining({ parentTaskId: parentRequest.requestId, @@ -73,13 +80,27 @@ describe('SubagentService', () => { expect(executionRequest?.trustedInstructions).toContain( expert.systemInstructions ) - expect(events).toEqual(['queued', 'running', 'completed']) + expect(events.map((event) => event.state)).toEqual([ + 'queued', + 'running', + 'completed' + ]) + expect(events.at(-1)).toMatchObject({ + state: 'completed', + output: expertOutput + }) await service.dispose() }) it('fails tool-producing experts and records bounded failure state', async () => { + const events: SubagentEvent[] = [] const runtime = { run: async function* (request: AgentExecutionRequest) { + yield { + requestId: request.requestId, + type: 'text', + delta: '部分结果' + } as const yield { requestId: request.requestId, type: 'tool', @@ -98,13 +119,18 @@ describe('SubagentService', () => { expert, routingMode: 'manual', signal: new AbortController().signal, - onEvent: vi.fn() + onEvent: (event) => events.push(event) })).rejects.toThrow('不允许工具调用') expect(db.updateTaskStatus).toHaveBeenLastCalledWith( expect.any(String), 'failed', expect.stringContaining('不允许工具调用') ) + expect(events.at(-1)).toMatchObject({ + state: 'failed', + output: '部分结果', + error: expect.stringContaining('不允许工具调用') + }) await service.dispose() }) diff --git a/src/main/assistant/subagent-service.ts b/src/main/assistant/subagent-service.ts index 46322ce..ccc6857 100644 --- a/src/main/assistant/subagent-service.ts +++ b/src/main/assistant/subagent-service.ts @@ -217,7 +217,7 @@ export class SubagentService { throw new Error(event.message) } if (event.type === 'text') { - output = `${output}${event.delta}`.slice(0, 60_000) + output = `${output}${event.delta}` } else if (event.type === 'done') { completed = true } @@ -226,7 +226,11 @@ export class SubagentService { throw new Error('专家子任务未报告完成') } this.database.updateTaskStatus(childTaskId, 'completed') - this.emit(input, { childTaskId, state: 'completed' }) + this.emit(input, { + childTaskId, + state: 'completed', + output + }) return { childTaskId, output } } catch (error) { const cancelled = scheduledSignal.aborted || input.signal.aborted @@ -240,6 +244,7 @@ export class SubagentService { this.emit(input, { childTaskId, state: cancelled ? 'cancelled' : 'failed', + output: output || undefined, error: message }) throw new SubagentRunError(message, output, { cause: error }) @@ -272,6 +277,7 @@ export class SubagentService { childTaskId: string state: SubagentEvent['state'] reason?: string + output?: string error?: string } ): void { @@ -286,6 +292,9 @@ export class SubagentService { ...(event.reason ? { reason: event.reason.slice(0, 240) } : {}), + ...(event.output !== undefined + ? { output: event.output } + : {}), ...(event.error ? { error: event.error.slice(0, 1_000) } : {}) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 9c7e8ec..ada009e 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -6461,24 +6461,53 @@ describe('App', () => { requestId: request.requestId, type: 'subagent', ...events[0]!, - state: 'completed' + state: 'completed', + output: '研究专家的独立结论' }) agentListener?.({ requestId: request.requestId, type: 'subagent', ...events[1]!, state: 'cancelled', - reason: '父任务已停止' + reason: '父任务已停止', + output: '代码专家的部分结论' }) }) expect(within(statusRegion).getByText('已完成')).toBeInTheDocument() expect(within(statusRegion).getByText('已取消')).toBeInTheDocument() expect(within(statusRegion).getByText('父任务已停止')).toBeInTheDocument() + expect( + within(statusRegion).getByText('研究专家的独立结论') + ).toBeInTheDocument() + expect( + within(statusRegion).getByText('代码专家的部分结论') + ).toBeInTheDocument() + await waitFor(() => { + const persistedMessages = vi + .mocked(api.conversations.saveLocal) + .mock.calls.flatMap(([batch]) => + batch.flatMap((conversation) => conversation.messages) + ) + expect( + persistedMessages.some( + (message) => + message.subagents?.[0]?.output === + '研究专家的独立结论' && + message.subagents?.[1]?.output === '代码专家的部分结论' + ) + ).toBe(true) + }) fireEvent.click(screen.getByText('运行记录')) expect(await screen.findAllByText('子专家')).toHaveLength(4) expect(screen.getAllByText(/智能路由/u).length).toBeGreaterThan(0) expect(screen.getAllByText(/手动指定/u).length).toBeGreaterThan(0) + expect( + screen.getByText(/研究专家的独立结论/u) + ).toBeInTheDocument() + expect( + screen.getByText(/代码专家的部分结论/u) + ).toBeInTheDocument() }) it('offers once, session, permanent, and deny for a tool call', async () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e503a44..895bcec 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -117,6 +117,7 @@ import { conversationContextCompressionMarkerSchema, conversationContextMetricsSchema, conversationMessageBlocksSchema, + conversationSubagentActivitySchema, interactiveWorkModes, normalizeInteractiveWorkMode, projectChannelLabels @@ -1090,6 +1091,14 @@ function isConversation(value: unknown): value is Conversation { compression ).success ))) && + (entry.subagents === undefined || + (Array.isArray(entry.subagents) && + entry.subagents.length <= 3 && + entry.subagents.every( + (subagent) => + conversationSubagentActivitySchema.safeParse(subagent) + .success + ))) && (entry.artifactIds === undefined || (Array.isArray(entry.artifactIds) && entry.artifactIds.length <= 8 && @@ -1139,6 +1148,7 @@ function toConversationMessage(message: Message): ConversationMessage { contextCompression: message.contextCompression, contextCompressions: message.contextCompressions, tools: message.tools, + subagents: message.subagents, sources: message.sources, sourceReferences: message.sourceReferences, knowledgeRetrieval: message.knowledgeRetrieval, @@ -3629,6 +3639,7 @@ function App(): React.JSX.Element { routingMode: event.routingMode, state: event.state, reason: event.reason, + output: event.output, error: event.error } if (index >= 0) { diff --git a/src/renderer/src/ChatTimeline.test.tsx b/src/renderer/src/ChatTimeline.test.tsx index 70fe5d6..aa73bc2 100644 --- a/src/renderer/src/ChatTimeline.test.tsx +++ b/src/renderer/src/ChatTimeline.test.tsx @@ -1,4 +1,10 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { + cleanup, + fireEvent, + render, + screen, + within +} from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { ChatTimeline, @@ -153,4 +159,78 @@ describe('ChatTimeline', () => { ) ).toBeInTheDocument() }) + + it('shows every parallel expert output in its own expandable card', () => { + const messages: Message[] = [ + { + id: 'assistant-message', + role: 'assistant', + content: '综合结果', + createdAt: 1_775_000_000_000, + state: 'complete', + subagents: [ + { + childTaskId: '00000000-0000-4000-8000-000000000101', + expertId: '00000000-0000-4000-8000-000000000201', + expertName: '研究专家', + routingMode: 'manual', + state: 'completed', + output: '研究专家的独立结论' + }, + { + childTaskId: '00000000-0000-4000-8000-000000000102', + expertId: '00000000-0000-4000-8000-000000000202', + expertName: '代码专家', + routingMode: 'manual', + state: 'completed', + output: '代码专家的独立结论' + }, + { + childTaskId: '00000000-0000-4000-8000-000000000103', + expertId: '00000000-0000-4000-8000-000000000203', + expertName: '安全专家', + routingMode: 'manual', + state: 'completed', + output: '安全专家的独立结论' + } + ] + } + ] + render( + + ) + + const region = screen.getByLabelText('子专家状态') + const summary = screen.getByText('综合结果').parentElement + const messageBody = region.parentElement + expect(summary?.parentElement).toBe(messageBody) + expect( + Array.from(messageBody?.children ?? []).indexOf(region) + ).toBeLessThan( + Array.from(messageBody?.children ?? []).indexOf(summary!) + ) + const cards = within(region).getAllByRole('group') + expect(cards).toHaveLength(3) + for (const [index, output] of [ + '研究专家的独立结论', + '代码专家的独立结论', + '安全专家的独立结论' + ].entries()) { + expect(within(cards[index]!).getByText(output)).toBeInTheDocument() + expect(cards[index]).not.toHaveAttribute('open') + fireEvent.click(within(cards[index]!).getByText(/专家$/u)) + expect(cards[index]).toHaveAttribute('open') + } + }) }) diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 11fe023..3bc2350 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -21,6 +21,7 @@ import type { ConversationContextCompressionMarker, ConversationMessage, ConversationMessageBlock, + ConversationSubagentActivity, ConversationToolActivity } from '../../shared/assistant-contracts' import { AgentQuestionCard } from './AgentQuestionCard' @@ -30,15 +31,7 @@ import { formatCompactTokens } from './token-format' export type ToolActivity = ConversationToolActivity -export type SubagentActivity = { - childTaskId: string - expertId: string - expertName: string - routingMode: 'manual' | 'smart' - state: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' - reason?: string - error?: string -} +export type SubagentActivity = ConversationSubagentActivity export type KnowledgeRetrievalStatus = Omit< Extract, @@ -223,6 +216,61 @@ function ToolExecutionList({ ) } +function SubagentStatusList({ + subagents +}: { + subagents: SubagentActivity[] +}): React.JSX.Element { + const { t } = useTranslation('app') + + return ( +
+ {subagents.slice(0, 3).map((subagent) => ( +
+ + +
+ {subagent.output ? ( +
+ {t('chat.subagents.output')} +
+ {subagent.output} +
+
+ ) : ( +

{t('chat.subagents.noOutput')}

+ )} + {(subagent.error || subagent.reason) && + (subagent.state === 'failed' || + subagent.state === 'cancelled') && ( +
+ {t('chat.subagents.error')} +

{subagent.error ?? subagent.reason}

+
+ )} +
+
+ ))} +
+ ) +} + type ChatMessageRowProps = { artifactById: ReadonlyMap canRetry: boolean @@ -406,6 +454,9 @@ function ChatMessageRowView({ })} )} + {message.subagents && message.subagents.length > 0 && ( + + )} {message.blocks && message.blocks.length > 0 ? (
{groupMessageBlocks(message.blocks).map((item) => @@ -638,37 +689,6 @@ function ChatMessageRowView({ message.tools.length > 0 && ( )} - {message.subagents && message.subagents.length > 0 && ( -
- {message.subagents.slice(0, 3).map((subagent) => ( -
-
- ))} -
- )} {message.approval && (
diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index b615c67..567d010 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -276,6 +276,9 @@ export const app = { smart: 'Smart routing', manual: 'Selected manually', fallbackTask: '{{name}} subagent task', + output: 'Expert output', + error: 'Execution details', + noOutput: 'This expert has no output to display yet.', states: { queued: 'Queued', running: 'Running', diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 99210d2..643f8ae 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -270,6 +270,9 @@ export const app = { smart: '智能路由', manual: '手动指定', fallbackTask: '{{name}} 子专家任务', + output: '专家输出', + error: '执行说明', + noOutput: '该专家暂时没有可显示的输出。', states: { queued: '等待中', running: '进行中', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 3ceb29d..f6ffa6c 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -3891,15 +3891,34 @@ button > svg { } .subagent-status-card { - display: grid; - align-items: center; - padding: var(--space-2) var(--space-3); border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-subtle); color: var(--text-secondary); + overflow: hidden; +} + +.subagent-status-card > summary { + display: grid; + align-items: center; + padding: var(--space-2) var(--space-3); + cursor: pointer; gap: var(--space-2); grid-template-columns: auto minmax(0, 1fr) auto; + list-style: none; +} + +.subagent-status-card > summary::-webkit-details-marker { + display: none; +} + +.subagent-status-card > summary:hover { + background: var(--accent-subtle); +} + +.subagent-status-card > summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; } .subagent-status-card--running { @@ -3912,30 +3931,51 @@ button > svg { background: var(--danger-subtle); } -.subagent-status-card > div { +.subagent-status-card__identity { display: grid; min-width: 0; gap: var(--space-1); } -.subagent-status-card strong, -.subagent-status-card span { +.subagent-status-card summary strong, +.subagent-status-card summary > span:last-child { color: var(--text-primary); font-size: var(--font-body); } -.subagent-status-card small { +.subagent-status-card summary small { color: var(--text-muted); font-size: var(--font-caption); } -.subagent-status-card p { +.subagent-status-card__details { + display: grid; + padding: var(--space-3); + border-top: 1px solid var(--border-subtle); + background: var(--surface-raised); + gap: var(--space-3); +} + +.subagent-status-card__details section { + display: grid; + gap: var(--space-2); +} + +.subagent-status-card__details p { margin: 0; - color: var(--danger); - font-size: var(--font-caption); overflow-wrap: anywhere; } +.subagent-status-card__details > p { + color: var(--text-muted); + font-size: var(--font-caption); +} + +.subagent-status-card__error, +.subagent-status-card__error p { + color: var(--danger); +} + .approval-card { display: grid; align-items: center; diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 650edca..a51362b 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -137,6 +137,29 @@ export type ConversationMessageBlock = z.infer< typeof conversationMessageBlockSchema > +export const conversationSubagentActivitySchema = z + .object({ + childTaskId: assistantIdSchema, + expertId: assistantIdSchema, + expertName: z.string().trim().min(1).max(80), + routingMode: z.enum(['manual', 'smart']), + state: z.enum([ + 'queued', + 'running', + 'completed', + 'failed', + 'cancelled' + ]), + reason: z.string().trim().min(1).max(240).optional(), + output: z.string().optional(), + error: z.string().trim().min(1).max(1_000).optional() + }) + .strict() + +export type ConversationSubagentActivity = z.infer< + typeof conversationSubagentActivitySchema +> + export const conversationContextCompressionMarkerSchema = z .object({ state: z.enum(['compressing', 'completed', 'failed']), @@ -168,6 +191,10 @@ export const conversationMessageSchema = z .max(2) .optional(), tools: z.array(conversationToolActivitySchema).max(100).optional(), + subagents: z + .array(conversationSubagentActivitySchema) + .max(3) + .optional(), sources: z.array(z.string().max(8_192)).max(100).optional(), sourceReferences: z .array( diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 2fa732c..d675735 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -957,6 +957,7 @@ export const subagentEventSchema = z 'cancelled' ]), reason: z.string().trim().min(1).max(240).optional(), + output: z.string().optional(), error: z.string().trim().min(1).max(1_000).optional() }) .strict()