Avoid noisy stopped states for background agents

Goal sessions were showing stopped background-agent cards because the parent model used TaskStop after reading enough partial review output. That writes a killed task notification, which the desktop then rendered like a failure.

TaskStop and async agent launch guidance now make cancellation an exceptional action rather than a normal cleanup step. Stopped background-agent transcript cards also render as neutral interrupted events instead of error-styled failures.

Constraint: Background agent task notifications must remain truthful; killed tasks still surface as stopped instead of being hidden.

Rejected: Hide stopped notifications in /goal sessions | this would mask real user cancellations and runaway-task stops.

Confidence: medium

Scope-risk: narrow

Directive: Do not encourage parent turns to kill background agents only because partial output was read.

Tested: cd desktop && bun run test src/components/chat/MessageList.test.tsx -t "renders stopped background agents as neutral transcript events"

Tested: cd desktop && bun run lint

Tested: bun run check:server
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 23:04:02 +08:00
parent e9ac5739bc
commit d2d27b3845
4 changed files with 43 additions and 4 deletions

View File

@ -251,6 +251,36 @@ describe('MessageList nested tool calls', () => {
expect(card.textContent).toContain('45s')
})
it('renders stopped background agents as neutral transcript events', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [{
id: 'background-task-agent-stopped',
type: 'background_task',
timestamp: 2,
task: {
taskId: 'agent-task-stopped',
toolUseId: 'agent-tool-stopped',
status: 'stopped',
taskType: 'local_agent',
summary: 'Agent "Code review for todo app" was stopped',
startedAt: 1,
updatedAt: 2,
},
}],
}),
},
})
render(<MessageList />)
const card = screen.getByTestId('background-task-event-card')
expect(card.getAttribute('data-status')).toBe('stopped')
expect(card.textContent).toContain('stopped')
expect(card.querySelector('.text-\\[var\\(--color-error\\)\\]')).toBeNull()
})
it('restores the full transcript when scrolling away from latest', async () => {
useChatStore.setState({
sessions: {

View File

@ -1,5 +1,5 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, LoaderCircle, Settings, Target, XCircle } from 'lucide-react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, LoaderCircle, Settings, Target, XCircle } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
@ -233,7 +233,8 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent })
const t = useTranslation()
const { task } = message
const isRunning = task.status === 'running'
const isFailed = task.status === 'failed' || task.status === 'stopped'
const isFailed = task.status === 'failed'
const isStopped = task.status === 'stopped'
const duration = formatBackgroundTaskDuration(task.usage?.durationMs)
const detail = task.summary || task.lastToolName || task.description || task.outputFile || task.taskId
@ -241,6 +242,7 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent })
<div className="mb-2">
<div
data-testid="background-task-event-card"
data-status={task.status}
className="flex min-w-0 items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"
>
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
@ -248,6 +250,8 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent })
<LoaderCircle size={15} strokeWidth={2.25} className="animate-spin text-[var(--color-accent)]" aria-hidden="true" />
) : isFailed ? (
<XCircle size={15} strokeWidth={2.25} className="text-[var(--color-error)]" aria-hidden="true" />
) : isStopped ? (
<CircleStop size={15} strokeWidth={2.25} className="text-[var(--color-text-tertiary)]" aria-hidden="true" />
) : (
<CheckCircle2 size={15} strokeWidth={2.25} className="text-[var(--color-success)]" aria-hidden="true" />
)}

View File

@ -1322,7 +1322,8 @@ The agent is now running and will receive instructions via mailbox.`
}
if (data.status === 'async_launched') {
const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.`;
const instructions = data.canReadOutputFile ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.`;
const stopGuidance = `Do not stop this agent just because you have enough partial output. Stop it only if the user asks to cancel it, or if it is clearly runaway, harmful, duplicative, or no longer useful.`;
const instructions = data.canReadOutputFile ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\n${stopGuidance}\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.\n${stopGuidance}`;
const text = `${prefix}\n${instructions}`;
return {
tool_use_id: toolUseID,

View File

@ -4,5 +4,9 @@ export const DESCRIPTION = `
- Stops a running background task by its ID
- Takes a task_id parameter identifying the task to stop
- Returns a success or failure status
- Use this tool when you need to terminate a long-running task
- Use this tool only when the user explicitly asks to cancel/stop a task, or when
the task is clearly runaway, harmful, duplicative, or no longer useful.
- Do not stop a background agent merely because you have already read enough of
its output. Prefer waiting for the automatic completion notification or
leaving it running while you summarize available progress.
`