Keep agent background progress inside agent cards

Desktop receives task lifecycle events for local subagents in addition to the visible Agent tool call. Rendering those events as standalone transcript cards exposed internal task types such as local_agent and duplicated the Agent surface. The store now keeps agent lifecycle state for Agent tool notifications without inserting separate transcript background cards, while non-agent background work remains visible with user-facing labels.

The long workspace preview test now has a wider timeout so coverage mode does not fail a behaviorally passing case.

Constraint: CLI task events use internal task_type values such as local_agent and local_bash.
Rejected: Rename local_agent in the standalone card | still leaves duplicate Agent and task cards for one subagent.
Confidence: high
Scope-risk: moderate
Directive: Do not render local_agent or remote_agent as standalone transcript cards unless the Agent tool card no longer exists.
Tested: bun run verify passed=8 failed=0 skipped=2
Not-tested: Live provider-backed desktop session smoke.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 23:41:57 +08:00
parent 11f83a76c5
commit d7315df18b
7 changed files with 235 additions and 57 deletions

View File

@ -200,7 +200,7 @@ describe('MessageList nested tool calls', () => {
expect(screen.getByText('Budget: 0 / unlimited tokens')).toBeTruthy()
})
it('renders background agent progress inline in the transcript', () => {
it('renders non-agent background progress inline in the transcript', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
@ -212,14 +212,14 @@ describe('MessageList nested tool calls', () => {
timestamp: 1,
},
{
id: 'background-task-agent-1',
id: 'background-task-shell-1',
type: 'background_task',
timestamp: 2,
task: {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
taskId: 'shell-task-1',
toolUseId: 'shell-tool-1',
status: 'running',
taskType: 'local_agent',
taskType: 'local_bash',
summary: 'Running Playwright checks',
usage: {
totalTokens: 1200,
@ -244,27 +244,27 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
const card = screen.getByTestId('background-task-event-card')
expect(card.textContent).toContain('local_agent')
expect(card.textContent).toContain('Background command')
expect(card.textContent).toContain('running')
expect(card.textContent).toContain('Running Playwright checks')
expect(card.textContent).toContain('1,200 tokens')
expect(card.textContent).toContain('45s')
})
it('renders stopped background agents as neutral transcript events', () => {
it('renders stopped non-agent background tasks as neutral transcript events', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [{
id: 'background-task-agent-stopped',
id: 'background-task-shell-stopped',
type: 'background_task',
timestamp: 2,
task: {
taskId: 'agent-task-stopped',
toolUseId: 'agent-tool-stopped',
taskId: 'shell-task-stopped',
toolUseId: 'shell-tool-stopped',
status: 'stopped',
taskType: 'local_agent',
summary: 'Agent "Code review for todo app" was stopped',
taskType: 'local_bash',
summary: 'Command "bun test" was stopped',
startedAt: 1,
updatedAt: 2,
},
@ -281,6 +281,77 @@ describe('MessageList nested tool calls', () => {
expect(card.querySelector('.text-\\[var\\(--color-error\\)\\]')).toBeNull()
})
it('uses user-facing labels for workflow and unknown background tasks', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'background-task-workflow',
type: 'background_task',
timestamp: 2,
task: {
taskId: 'workflow-task',
status: 'running',
taskType: 'local_workflow',
summary: 'Running release checklist',
startedAt: 1,
updatedAt: 2,
},
},
{
id: 'background-task-unknown',
type: 'background_task',
timestamp: 3,
task: {
taskId: 'unknown-task',
status: 'completed',
summary: 'Finished background work',
startedAt: 1,
updatedAt: 3,
},
},
],
}),
},
})
render(<MessageList />)
const cards = screen.getAllByTestId('background-task-event-card')
expect(cards).toHaveLength(2)
expect(cards[0]?.textContent).toContain('Background workflow')
expect(cards[1]?.textContent).toContain('Background task')
})
it('does not render agent background task events as separate transcript cards', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [{
id: 'background-task-agent-hidden',
type: 'background_task',
timestamp: 2,
task: {
taskId: 'agent-task-hidden',
toolUseId: 'agent-tool-hidden',
status: 'running',
taskType: 'local_agent',
summary: 'Running Read',
startedAt: 1,
updatedAt: 2,
},
}],
}),
},
})
render(<MessageList />)
expect(screen.queryByTestId('background-task-event-card')).toBeNull()
expect(screen.queryByText('local_agent')).toBeNull()
})
it('restores the full transcript when scrolling away from latest', async () => {
useChatStore.setState({
sessions: {

View File

@ -219,6 +219,7 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent })
const isStopped = task.status === 'stopped'
const duration = formatBackgroundTaskDuration(task.usage?.durationMs)
const detail = task.summary || task.lastToolName || task.description || task.outputFile || task.taskId
const label = getBackgroundTaskLabel(task.taskType, t)
return (
<div className="mb-2">
@ -242,7 +243,7 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent })
<div className="flex min-w-0 items-center gap-2">
<Bot size={14} strokeWidth={2.25} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<span className="shrink-0 text-[12px] font-medium text-[var(--color-text-primary)]">
{task.taskType || t('chat.backgroundAgents.agent')}
{label}
</span>
<span className="shrink-0 text-[11px] text-[var(--color-text-tertiary)]">
{t(`chat.backgroundAgents.status.${task.status}`)}
@ -267,6 +268,25 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent })
)
}
function isAgentBackgroundTaskMessage(message: UIMessage): boolean {
if (message.type !== 'background_task') return false
if (message.task.taskType === 'local_agent' || message.task.taskType === 'remote_agent') {
return true
}
return /^Agent (?:(?:"[^"]+" )?(completed|was stopped)|(?:"[^"]+" )?failed(?::|$))/.test(
message.task.summary ?? '',
)
}
function getBackgroundTaskLabel(
taskType: string | undefined,
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
if (taskType === 'local_bash') return t('chat.backgroundTasks.command')
if (taskType === 'local_workflow') return t('chat.backgroundTasks.workflow')
return t('chat.backgroundTasks.task')
}
function SelectableChatMessage({
sessionId,
messageId,
@ -377,6 +397,9 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
if (msg.type === 'assistant_text' && !msg.content.trim()) {
continue
}
if (isAgentBackgroundTaskMessage(msg)) {
continue
}
if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) {
continue
@ -412,7 +435,7 @@ function isTurnResponseMessage(message: UIMessage) {
message.type === 'assistant_text' ||
message.type === 'tool_use' ||
message.type === 'tool_result' ||
message.type === 'background_task' ||
(message.type === 'background_task' && !isAgentBackgroundTaskMessage(message)) ||
message.type === 'error' ||
message.type === 'task_summary'
)

View File

@ -970,7 +970,7 @@ describe('WorkspacePanel', () => {
expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300')
})
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
}, 10_000)
}, 20_000)
it('renders image previews from workspace files', async () => {
await setWorkspaceState((state) => ({

View File

@ -966,6 +966,9 @@ export const en = {
'chat.backgroundAgents.title': 'Background agents',
'chat.backgroundAgents.count': '{count} active or recent',
'chat.backgroundAgents.agent': 'agent',
'chat.backgroundTasks.command': 'Background command',
'chat.backgroundTasks.workflow': 'Background workflow',
'chat.backgroundTasks.task': 'Background task',
'chat.backgroundAgents.tokens': '{count} tokens',
'chat.backgroundAgents.status.running': 'running',
'chat.backgroundAgents.status.completed': 'completed',

View File

@ -968,6 +968,9 @@ export const zh: Record<TranslationKey, string> = {
'chat.backgroundAgents.title': '后台 Agent',
'chat.backgroundAgents.count': '{count} 个运行中或最近任务',
'chat.backgroundAgents.agent': 'agent',
'chat.backgroundTasks.command': '后台命令',
'chat.backgroundTasks.workflow': '后台工作流',
'chat.backgroundTasks.task': '后台任务',
'chat.backgroundAgents.tokens': '{count} tokens',
'chat.backgroundAgents.status.running': '运行中',
'chat.backgroundAgents.status.completed': '已完成',

View File

@ -436,15 +436,6 @@ describe('chatStore history mapping', () => {
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.messages).toMatchObject([
{ id: 'visible-message', type: 'assistant_text', content: 'already rendered' },
{
type: 'background_task',
task: {
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'completed',
summary: 'Agent completed',
},
},
{
id: 'goal-complete',
type: 'goal_event',
@ -1255,7 +1246,7 @@ describe('chatStore history mapping', () => {
})
})
it('tracks background agent task lifecycle events for desktop visibility', () => {
it('tracks background agent task lifecycle without duplicating transcript cards', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-06T00:00:01.000Z'))
@ -1289,18 +1280,7 @@ describe('chatStore history mapping', () => {
taskType: 'local_agent',
prompt: 'Run E2E verification',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'background_task',
task: {
taskId: 'agent-task-1',
status: 'running',
description: 'Verify the todo app',
},
},
])
const insertedTaskTimestamp = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp
expect(insertedTaskTimestamp).toBe(new Date('2026-04-06T00:00:02.000Z').getTime())
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
vi.setSystemTime(new Date('2026-04-06T00:00:03.000Z'))
@ -1331,16 +1311,7 @@ describe('chatStore history mapping', () => {
durationMs: 45000,
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(1)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]).toMatchObject({
type: 'background_task',
task: {
taskId: 'agent-task-1',
status: 'running',
summary: 'Running Playwright checks',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp).toBe(insertedTaskTimestamp)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
vi.setSystemTime(new Date('2026-04-06T00:00:04.000Z'))
@ -1376,19 +1347,110 @@ describe('chatStore history mapping', () => {
summary: 'Found and fixed localStorage corruption.',
outputFile: '/tmp/agent-output.txt',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(1)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]).toMatchObject({
type: 'background_task',
task: {
taskId: 'agent-task-1',
status: 'completed',
summary: 'Found and fixed localStorage corruption.',
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
vi.useRealTimers()
})
it('keeps non-agent background tasks visible and updates the existing transcript card', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-06T00:00:01.000Z'))
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession(),
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp).toBe(insertedTaskTimestamp)
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_started',
data: {
task_id: 'shell-task-1',
tool_use_id: 'shell-tool-1',
description: 'Run desktop checks',
task_type: 'local_bash',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'background_task',
task: {
taskId: 'shell-task-1',
toolUseId: 'shell-tool-1',
status: 'running',
taskType: 'local_bash',
description: 'Run desktop checks',
},
},
])
const insertedTaskTimestamp = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp
vi.setSystemTime(new Date('2026-04-06T00:00:02.000Z'))
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_progress',
data: {
task_id: 'shell-task-1',
tool_use_id: 'shell-tool-1',
description: 'Run desktop checks',
summary: 'Running Vitest',
last_tool_name: 'Bash',
task_type: 'local_bash',
},
})
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.messages).toHaveLength(1)
expect(session?.messages[0]).toMatchObject({
type: 'background_task',
task: {
taskId: 'shell-task-1',
status: 'running',
summary: 'Running Vitest',
lastToolName: 'Bash',
},
})
expect(session?.messages[0]?.timestamp).toBe(insertedTaskTimestamp)
vi.useRealTimers()
})
it('removes stale agent task transcript cards by matching tool use id', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
messages: [{
id: 'background-task-old-agent-task',
type: 'background_task',
timestamp: 1,
task: {
taskId: 'old-agent-task',
toolUseId: 'agent-tool-1',
status: 'running',
taskType: 'local_agent',
startedAt: 1,
updatedAt: 1,
},
}],
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_started',
data: {
task_id: 'new-agent-task',
tool_use_id: 'agent-tool-1',
task_type: 'local_agent',
description: 'Review app',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
})
it('clears local desktop chat state when the server confirms /clear', () => {
vi.useFakeTimers()

View File

@ -234,10 +234,17 @@ function upsertBackgroundTaskMessage(
task: BackgroundAgentTask,
timestamp: number,
): UIMessage[] {
const existingIndex = messages.findIndex((message) =>
const isSameTaskMessage = (message: UIMessage) =>
message.type === 'background_task' &&
(message.task.taskId === task.taskId ||
(task.toolUseId && message.task.toolUseId === task.toolUseId)))
(task.toolUseId && message.task.toolUseId === task.toolUseId))
if (isAgentBackgroundTask(task)) {
return messages.filter((message) => !isSameTaskMessage(message))
}
const existingIndex = messages.findIndex((message) =>
isSameTaskMessage(message))
if (existingIndex === -1) {
return [...messages, {
id: `background-task-${task.taskId}`,
@ -264,6 +271,15 @@ function mergeBackgroundTaskMessages(
return [...merged].sort((a, b) => a.timestamp - b.timestamp)
}
function isAgentBackgroundTask(task: Pick<BackgroundAgentTask, 'taskType' | 'summary'>): boolean {
if (task.taskType === 'local_agent' || task.taskType === 'remote_agent') {
return true
}
return /^Agent (?:(?:"[^"]+" )?(completed|was stopped)|(?:"[^"]+" )?failed(?::|$))/.test(
task.summary ?? '',
)
}
function mergeRestoredTerminalGoalEvents(
messages: UIMessage[],
restoredMessages: UIMessage[],