fix: show expert outputs before synthesis

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 的综合结果。
This commit is contained in:
mesalogo
2026-08-19 01:32:19 +08:00
parent 87dec9baf3
commit 28b1590749
12 changed files with 311 additions and 62 deletions
+32 -6
View File
@@ -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()
})
+11 -2
View File
@@ -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) }
: {})
+31 -2
View File
@@ -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 () => {
+11
View File
@@ -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) {
+81 -1
View File
@@ -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(
<ChatTimeline
artifactById={new Map()}
conversationId="conversation-1"
hiddenMessageCount={0}
isUnusedConversation={false}
locale="zh-CN"
messageStartIndex={0}
messages={messages}
{...callbacks}
retryContent=""
totalMessageCount={messages.length}
/>
)
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')
}
})
})
+60 -40
View File
@@ -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<AgentEvent, { type: 'knowledge-retrieval' }>,
@@ -223,6 +216,61 @@ function ToolExecutionList({
)
}
function SubagentStatusList({
subagents
}: {
subagents: SubagentActivity[]
}): React.JSX.Element {
const { t } = useTranslation('app')
return (
<section
aria-label={t('chat.subagents.region')}
className="subagent-status-list"
>
{subagents.slice(0, 3).map((subagent) => (
<details
className={`subagent-status-card subagent-status-card--${subagent.state}`}
key={subagent.childTaskId}
>
<summary>
<Bot aria-hidden="true" size={15} />
<span className="subagent-status-card__identity">
<strong>{subagent.expertName}</strong>
<small>
{subagent.routingMode === 'smart'
? t('chat.subagents.smart')
: t('chat.subagents.manual')}
</small>
</span>
<span>{t(`chat.subagents.states.${subagent.state}`)}</span>
</summary>
<div className="subagent-status-card__details">
{subagent.output ? (
<section>
<strong>{t('chat.subagents.output')}</strong>
<div className="markdown-content">
<MarkdownRenderer>{subagent.output}</MarkdownRenderer>
</div>
</section>
) : (
<p>{t('chat.subagents.noOutput')}</p>
)}
{(subagent.error || subagent.reason) &&
(subagent.state === 'failed' ||
subagent.state === 'cancelled') && (
<section className="subagent-status-card__error">
<strong>{t('chat.subagents.error')}</strong>
<p>{subagent.error ?? subagent.reason}</p>
</section>
)}
</div>
</details>
))}
</section>
)
}
type ChatMessageRowProps = {
artifactById: ReadonlyMap<string, AssistantArtifact>
canRetry: boolean
@@ -406,6 +454,9 @@ function ChatMessageRowView({
})}
</div>
)}
{message.subagents && message.subagents.length > 0 && (
<SubagentStatusList subagents={message.subagents} />
)}
{message.blocks && message.blocks.length > 0 ? (
<div className="message-blocks">
{groupMessageBlocks(message.blocks).map((item) =>
@@ -638,37 +689,6 @@ function ChatMessageRowView({
message.tools.length > 0 && (
<ToolExecutionList tools={message.tools} />
)}
{message.subagents && message.subagents.length > 0 && (
<section
aria-label={t('chat.subagents.region')}
className="subagent-status-list"
>
{message.subagents.slice(0, 3).map((subagent) => (
<article
className={`subagent-status-card subagent-status-card--${subagent.state}`}
key={subagent.childTaskId}
>
<Bot aria-hidden="true" size={15} />
<div>
<strong>{subagent.expertName}</strong>
<small>
{subagent.routingMode === 'smart'
? t('chat.subagents.smart')
: t('chat.subagents.manual')}
</small>
{(subagent.error || subagent.reason) &&
(subagent.state === 'failed' ||
subagent.state === 'cancelled') && (
<p>{subagent.error ?? subagent.reason}</p>
)}
</div>
<span>
{t(`chat.subagents.states.${subagent.state}`)}
</span>
</article>
))}
</section>
)}
{message.approval && (
<div className="approval-card">
<ShieldCheck size={18} />
@@ -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',
@@ -270,6 +270,9 @@ export const app = {
smart: '智能路由',
manual: '手动指定',
fallbackTask: '{{name}} 子专家任务',
output: '专家输出',
error: '执行说明',
noOutput: '该专家暂时没有可显示的输出。',
states: {
queued: '等待中',
running: '进行中',
+50 -10
View File
@@ -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;
+27
View File
@@ -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(
+1
View File
@@ -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()