fix(desktop): control background task lifecycle #990

Keep AutoDream visible without marking the foreground session busy, add a task-scoped stop control, and preserve the authoritative stopped bookend emitted by the CLI runtime.

Tested: bun run check:desktop

Tested: bun run check:server

Tested: bun run check:chat-contract

Tested: bun run check:coverage

Tested: real MiniMax-M3 browser smoke for background stop and AutoDream busy-state isolation

Confidence: high

Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-07-11 01:50:33 +08:00
parent 060a134ca1
commit d6c156af17
19 changed files with 592 additions and 18 deletions

View File

@ -24,6 +24,8 @@ vi.mock('../../i18n', () => ({
'session.activity.openTeamMember': 'Open team member {name}',
'session.activity.openRun': 'Open run {name}',
'session.activity.openBackgroundTask': 'Open background task {name}',
'session.activity.stopBackgroundTask': 'Stop background task {name}',
'session.activity.stoppingBackgroundTask': 'Stopping background task {name}',
'session.activity.details.title': 'Details',
'session.activity.details.type': 'Type',
'session.activity.details.description': 'Description',
@ -257,6 +259,92 @@ describe('SessionActivityPanel', () => {
expect(onClearFinishedBackgroundTasks).toHaveBeenCalledWith(['bash-task-1:completed:1000'])
})
it('stops running background tasks and disables repeat requests while stopping', () => {
const onStopBackgroundTask = vi.fn()
const runningModel = model({
sections: {
...model().sections,
tasks: { id: 'tasks', title: 'Tasks', emptyLabel: 'No tasks', rows: [] },
subagents: { id: 'subagents', title: 'SubAgents', emptyLabel: 'No SubAgents', rows: [] },
backgroundTasks: {
id: 'backgroundTasks',
title: 'Background Tasks',
emptyLabel: 'No background tasks',
rows: [{
id: 'bash-task-1',
section: 'backgroundTasks',
label: 'Sleep for 300 seconds',
status: 'running',
taskId: 'bash-task-1',
taskType: 'local_bash',
openable: true,
}],
},
},
})
const { rerender } = render(
<SessionActivityPanel
model={runningModel}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
onStopBackgroundTask={onStopBackgroundTask}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Stop background task Sleep for 300 seconds' }))
expect(onStopBackgroundTask).toHaveBeenCalledWith('bash-task-1')
rerender(
<SessionActivityPanel
model={runningModel}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
onStopBackgroundTask={onStopBackgroundTask}
stoppingBackgroundTaskIds={{ 'bash-task-1': true }}
/>,
)
expect(screen.getByRole('button', { name: 'Stopping background task Sleep for 300 seconds' })).toBeDisabled()
})
it('offers the same stop control for a running background SubAgent', () => {
const onStopBackgroundTask = vi.fn()
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
tasks: { id: 'tasks', title: 'Tasks', emptyLabel: 'No tasks', rows: [] },
subagents: {
id: 'subagents',
title: 'SubAgents',
emptyLabel: 'No SubAgents',
rows: [{
id: 'agent-task-1',
section: 'subagents',
label: 'Background reviewer',
status: 'running',
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
openable: true,
}],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
onStopBackgroundTask={onStopBackgroundTask}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Stop background task Background reviewer' }))
expect(onStopBackgroundTask).toHaveBeenCalledWith('agent-task-1')
})
it('keeps SubAgent rows to name and status instead of result previews', () => {
render(
<SessionActivityPanel

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronRight, Circle, FileText, LoaderCircle, Terminal, Users, X } from 'lucide-react'
import { Check, ChevronRight, Circle, FileText, LoaderCircle, Square, Terminal, Users, X } from 'lucide-react'
import { AgentMascot } from './AgentMascot'
import { getVisibleActivitySections, type ActivityRow, type ActivitySectionId, type SessionActivityModel } from './sessionActivityModel'
import { useTranslation } from '../../i18n'
@ -245,12 +245,48 @@ function ActivityStatusIndicator({
)
}
function BackgroundTaskStopButton({
row,
stopping,
onStop,
}: {
row: ActivityRow
stopping: boolean
onStop: (taskId: string) => void
}) {
const t = useTranslation()
if (row.status !== 'running' || !row.taskId) return null
const label = stopping
? t('session.activity.stoppingBackgroundTask', { name: row.label })
: t('session.activity.stopBackgroundTask', { name: row.label })
return (
<button
type="button"
aria-label={label}
title={label}
disabled={stopping}
onClick={() => onStop(row.taskId!)}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[var(--color-text-tertiary)] transition-[background-color,color,transform] duration-150 ease-out hover:bg-[var(--color-error)]/10 hover:text-[var(--color-error)] active:translate-y-px disabled:cursor-wait disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{stopping ? (
<LoaderCircle size={14} strokeWidth={2.2} className="motion-safe:animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : (
<Square size={12} strokeWidth={2.4} aria-hidden="true" />
)}
</button>
)
}
function ActivityRowView({
row,
sessionId,
onOpenSubagent,
onOpenMember,
onOpenBackgroundTask,
onStopBackgroundTask,
stoppingBackgroundTask,
selected,
}: {
row: ActivityRow
@ -258,6 +294,8 @@ function ActivityRowView({
onOpenSubagent: (payload: OpenSubagentPayload) => void
onOpenMember?: (member: TeamMember) => void
onOpenBackgroundTask?: (row: ActivityRow) => void
onStopBackgroundTask?: (taskId: string) => void
stoppingBackgroundTask?: boolean
selected?: boolean
}) {
const t = useTranslation()
@ -312,7 +350,14 @@ function ActivityRowView({
</>
)
const interactiveRowClassName =
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
'flex min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-2.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
const stopButton = onStopBackgroundTask ? (
<BackgroundTaskStopButton
row={row}
stopping={Boolean(stoppingBackgroundTask)}
onStop={onStopBackgroundTask}
/>
) : null
if (row.section === 'team' && row.member && onOpenMember) {
return (
@ -320,7 +365,7 @@ function ActivityRowView({
type="button"
aria-label={t('session.activity.openTeamMember', { name: row.label })}
onClick={() => onOpenMember(row.member!)}
className={interactiveRowClassName}
className={`${interactiveRowClassName} w-full`}
>
{content}
</button>
@ -329,31 +374,55 @@ function ActivityRowView({
if (row.section === 'subagents' && row.openable && row.toolUseId) {
const statusLabel = getActivityStatusLabel(row.status, t)
return (
const openButton = (
<button
type="button"
aria-label={`${t('session.activity.openRun', { name: row.label })} · ${statusLabel}`}
onClick={() => onOpenSubagent({ sessionId, toolUseId: row.toolUseId!, title: row.label })}
className={interactiveRowClassName}
className={`${interactiveRowClassName} ${stopButton ? 'flex-1' : 'w-full'}`}
>
{content}
</button>
)
return stopButton ? (
<div className="flex w-full items-center gap-1">
{openButton}
{stopButton}
</div>
) : openButton
}
if (row.section === 'backgroundTasks' && onOpenBackgroundTask && hasBackgroundTaskDetails(row)) {
return (
const openButton = (
<button
type="button"
aria-label={t('session.activity.openBackgroundTask', { name: row.label })}
aria-expanded={selected}
onClick={() => onOpenBackgroundTask(row)}
className={`${interactiveRowClassName} ${selected ? 'bg-[var(--color-surface-container)]' : ''}`}
className={`${interactiveRowClassName} ${stopButton ? 'flex-1' : 'w-full'} ${selected ? 'bg-[var(--color-surface-container)]' : ''}`}
>
{content}
</button>
)
return stopButton ? (
<div className="flex w-full items-center gap-1">
{openButton}
{stopButton}
</div>
) : openButton
}
if (stopButton) {
return (
<div className="flex w-full items-center gap-1">
<div className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2.5 py-2.5">
{content}
</div>
{stopButton}
</div>
)
}
return (
@ -420,6 +489,8 @@ export function SessionActivityPanel({
onOpenSubagent,
onClearFinishedBackgroundTasks,
onOpenMember,
onStopBackgroundTask,
stoppingBackgroundTaskIds,
placement = 'overlay',
}: {
model: SessionActivityModel
@ -428,6 +499,8 @@ export function SessionActivityPanel({
onOpenSubagent: (payload: OpenSubagentPayload) => void
onClearFinishedBackgroundTasks?: (taskKeys: string[]) => void
onOpenMember?: (member: TeamMember) => void
onStopBackgroundTask?: (taskId: string) => void
stoppingBackgroundTaskIds?: Record<string, boolean>
placement?: SessionActivityPanelPlacement
}) {
const t = useTranslation()
@ -544,6 +617,8 @@ export function SessionActivityPanel({
sessionId={model.sessionId}
onOpenSubagent={onOpenSubagent}
onOpenMember={onOpenMember}
onStopBackgroundTask={onStopBackgroundTask}
stoppingBackgroundTask={Boolean(row.taskId && stoppingBackgroundTaskIds?.[row.taskId])}
onOpenBackgroundTask={(backgroundRow) => {
setSelectedBackgroundTaskId((current) => (
current === backgroundRow.id ? null : backgroundRow.id

View File

@ -1699,6 +1699,8 @@ export const en = {
'session.activity.openTeamMember': 'Open team member {name}',
'session.activity.openRun': 'Open run {name}',
'session.activity.openBackgroundTask': 'Open background task {name}',
'session.activity.stopBackgroundTask': 'Stop background task {name}',
'session.activity.stoppingBackgroundTask': 'Stopping background task {name}',
'session.activity.details.title': 'Details',
'session.activity.details.type': 'Type',
'session.activity.details.description': 'Description',

View File

@ -1701,6 +1701,8 @@ export const jp: Record<TranslationKey, string> = {
'session.activity.openTeamMember': 'チームメンバー {name} を開く',
'session.activity.openRun': '実行 {name} を開く',
'session.activity.openBackgroundTask': 'バックグラウンドタスク {name} を開く',
'session.activity.stopBackgroundTask': 'バックグラウンドタスク {name} を停止',
'session.activity.stoppingBackgroundTask': 'バックグラウンドタスク {name} を停止中',
'session.activity.details.title': '詳細',
'session.activity.details.type': '種類',
'session.activity.details.description': '説明',

View File

@ -1701,6 +1701,8 @@ export const kr: Record<TranslationKey, string> = {
'session.activity.openTeamMember': '팀 멤버 {name} 열기',
'session.activity.openRun': '실행 {name} 열기',
'session.activity.openBackgroundTask': '백그라운드 작업 {name} 열기',
'session.activity.stopBackgroundTask': '백그라운드 작업 {name} 중지',
'session.activity.stoppingBackgroundTask': '백그라운드 작업 {name} 중지 중',
'session.activity.details.title': '세부 정보',
'session.activity.details.type': '유형',
'session.activity.details.description': '설명',

View File

@ -1701,6 +1701,8 @@ export const zh: Record<TranslationKey, string> = {
'session.activity.openTeamMember': '開啟團隊成員 {name}',
'session.activity.openRun': '開啟執行 {name}',
'session.activity.openBackgroundTask': '開啟後台任務 {name}',
'session.activity.stopBackgroundTask': '停止後台任務 {name}',
'session.activity.stoppingBackgroundTask': '正在停止後台任務 {name}',
'session.activity.details.title': '詳情',
'session.activity.details.type': '類型',
'session.activity.details.description': '描述',

View File

@ -1701,6 +1701,8 @@ export const zh: Record<TranslationKey, string> = {
'session.activity.openTeamMember': '打开团队成员 {name}',
'session.activity.openRun': '打开运行 {name}',
'session.activity.openBackgroundTask': '打开后台任务 {name}',
'session.activity.stopBackgroundTask': '停止后台任务 {name}',
'session.activity.stoppingBackgroundTask': '正在停止后台任务 {name}',
'session.activity.details.title': '详情',
'session.activity.details.type': '类型',
'session.activity.details.description': '描述',

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import type { BackgroundAgentTask } from '../types/chat'
import { hasRunningBackgroundTasks } from './backgroundTasks'
function task(
taskId: string,
overrides: Partial<BackgroundAgentTask> = {},
): BackgroundAgentTask {
return {
taskId,
status: 'running',
startedAt: 1,
updatedAt: 1,
...overrides,
}
}
describe('hasRunningBackgroundTasks', () => {
it('does not treat AutoDream as foreground session activity', () => {
expect(hasRunningBackgroundTasks({
dream: task('dream', { taskType: 'dream' }),
})).toBe(false)
})
it('still reports user-started background tasks as running', () => {
expect(hasRunningBackgroundTasks({
shell: task('shell', { taskType: 'local_bash' }),
dream: task('dream', { taskType: 'dream' }),
})).toBe(true)
})
})

View File

@ -4,7 +4,11 @@ import type { TranslationKey } from '../i18n'
type Translator = (key: TranslationKey, params?: Record<string, string | number>) => string
export function hasRunningBackgroundTasks(tasks?: Record<string, BackgroundAgentTask>): boolean {
return Object.values(tasks ?? {}).some((task) => task.status === 'running')
// AutoDream is detached maintenance work: it remains visible and stoppable
// in Activity, but must not keep the foreground conversation marked busy.
return Object.values(tasks ?? {}).some(
(task) => task.status === 'running' && task.taskType !== 'dream',
)
}
export function createBackgroundTaskDismissKey(task: BackgroundAgentTask): string {

View File

@ -293,6 +293,7 @@ export function ActiveSession() {
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null)
const sessions = useSessionStore((s) => s.sessions)
const connectToSession = useChatStore((s) => s.connectToSession)
const stopBackgroundTask = useChatStore((s) => s.stopBackgroundTask)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const pendingComputerUsePermission = sessionState?.pendingComputerUsePermission ?? null
const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks)
@ -314,6 +315,7 @@ export function ActiveSession() {
const chatState = sessionState?.chatState ?? 'idle'
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
const hasRunningBackgroundTasks = hasAnyRunningBackgroundTasks(sessionState?.backgroundAgentTasks)
const stoppingBackgroundTaskIds = sessionState?.stoppingBackgroundTaskIds
const session = sessions.find((s) => s.id === activeTabId)
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
@ -485,6 +487,10 @@ export function ActiveSession() {
if (!activeTabId || taskKeys.length === 0) return
dismissBackgroundTaskKeys(activeTabId, taskKeys)
}, [activeTabId, dismissBackgroundTaskKeys])
const handleStopBackgroundTask = useCallback((taskId: string) => {
if (!activeTabId) return
stopBackgroundTask(activeTabId, taskId)
}, [activeTabId, stopBackgroundTask])
const lastUpdated = useMemo(() => {
if (!session?.modifiedAt) return ''
@ -680,6 +686,8 @@ export function ActiveSession() {
onOpenSubagent={handleOpenSubagentRun}
onClearFinishedBackgroundTasks={handleClearFinishedBackgroundTasks}
onOpenMember={handleOpenTeamMember}
onStopBackgroundTask={handleStopBackgroundTask}
stoppingBackgroundTaskIds={stoppingBackgroundTaskIds}
placement="overlay"
/>
) : null}
@ -725,6 +733,8 @@ export function ActiveSession() {
onOpenSubagent={handleOpenSubagentRun}
onClearFinishedBackgroundTasks={handleClearFinishedBackgroundTasks}
onOpenMember={handleOpenTeamMember}
onStopBackgroundTask={handleStopBackgroundTask}
stoppingBackgroundTaskIds={stoppingBackgroundTaskIds}
placement="rail"
/>
) : null}

View File

@ -3344,6 +3344,114 @@ describe('chatStore history mapping', () => {
vi.useRealTimers()
})
it('requests a background task stop and waits for the terminal event', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'idle',
backgroundAgentTasks: {
'bash-task-1': {
taskId: 'bash-task-1',
taskType: 'local_bash',
status: 'running',
startedAt: 1,
updatedAt: 1,
},
},
}),
},
})
useChatStore.getState().stopBackgroundTask(TEST_SESSION_ID, 'bash-task-1')
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
type: 'stop_background_task',
taskId: 'bash-task-1',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.stoppingBackgroundTaskIds).toEqual({
'bash-task-1': true,
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['bash-task-1']?.status).toBe('running')
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_notification',
data: {
task_id: 'bash-task-1',
status: 'stopped',
summary: 'Sleep stopped',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['bash-task-1']?.status).toBe('stopped')
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.stoppingBackgroundTaskIds).toEqual({})
})
it('clears the pending stop marker when the server rejects the request', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'idle',
backgroundAgentTasks: {
'bash-task-1': {
taskId: 'bash-task-1',
taskType: 'local_bash',
status: 'running',
startedAt: 1,
updatedAt: 1,
},
},
stoppingBackgroundTaskIds: { 'bash-task-1': true },
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'background_task_stop_failed',
taskId: 'bash-task-1',
message: 'Task is not running',
})
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.stoppingBackgroundTaskIds).toEqual({})
expect(session?.messages.at(-1)).toMatchObject({
type: 'error',
code: 'STOP_BACKGROUND_TASK_FAILED',
message: 'Task is not running',
})
})
it('does not surface a stop error when the task already finished naturally', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'idle',
messages: [{ id: 'done', type: 'assistant_text', content: 'Done', timestamp: 1 }],
backgroundAgentTasks: {
'bash-task-1': {
taskId: 'bash-task-1',
taskType: 'local_bash',
status: 'completed',
startedAt: 1,
updatedAt: 2,
},
},
stoppingBackgroundTaskIds: { 'bash-task-1': true },
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'background_task_stop_failed',
taskId: 'bash-task-1',
message: 'Task is not running',
})
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.stoppingBackgroundTaskIds).toEqual({})
expect(session?.messages).toHaveLength(1)
})
it('clears local desktop chat state when the server confirms /clear', () => {
vi.useFakeTimers()

View File

@ -127,6 +127,7 @@ export type PerSessionState = {
slashCommands: Array<{ name: string; description: string; argumentHint?: string }>
agentTaskNotifications: Record<string, AgentTaskNotification>
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
stoppingBackgroundTaskIds?: Record<string, boolean>
suppressNextTaskNotificationResponse?: boolean
activeGoal?: ActiveGoalState | null
elapsedTimer: ReturnType<typeof setInterval> | null
@ -166,6 +167,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
slashCommands: [],
agentTaskNotifications: {},
backgroundAgentTasks: {},
stoppingBackgroundTaskIds: {},
suppressNextTaskNotificationResponse: false,
activeGoal: null,
elapsedTimer: null,
@ -282,6 +284,7 @@ type ChatStore = {
setSessionRuntime: (sessionId: string, selection: RuntimeSelection) => void
setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void
stopGeneration: (sessionId: string) => void
stopBackgroundTask: (sessionId: string, taskId: string) => void
loadHistory: (sessionId: string) => Promise<void>
reloadHistory: (
sessionId: string,
@ -1345,6 +1348,22 @@ export const useChatStore = create<ChatStore>((set, get) => ({
useTabStore.getState().updateTabStatus(sessionId, hasRunningBackgroundAgents ? 'running' : 'idle')
},
stopBackgroundTask: (sessionId, taskId) => {
const session = get().sessions[sessionId]
const task = session?.backgroundAgentTasks?.[taskId]
if (!task || task.status !== 'running' || session?.stoppingBackgroundTaskIds?.[taskId]) return
set((state) => ({
sessions: updateSessionIn(state.sessions, sessionId, (current) => ({
stoppingBackgroundTaskIds: {
...current.stoppingBackgroundTaskIds,
[taskId]: true,
},
})),
}))
wsManager.send(sessionId, { type: 'stop_background_task', taskId })
},
loadHistory: async (sessionId) => {
const existingLoad = historyLoadsInFlight.get(sessionId)
if (existingLoad) return existingLoad
@ -2487,6 +2506,29 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}
break
case 'background_task_stop_failed':
update((session) => {
const stoppingBackgroundTaskIds = { ...session.stoppingBackgroundTaskIds }
delete stoppingBackgroundTaskIds[msg.taskId]
const taskAlreadyFinished = session.backgroundAgentTasks?.[msg.taskId]?.status !== 'running'
return {
stoppingBackgroundTaskIds,
...(taskAlreadyFinished ? {} : {
messages: [
...session.messages,
{
id: nextId(),
type: 'error',
message: msg.message,
code: 'STOP_BACKGROUND_TASK_FAILED',
timestamp: Date.now(),
},
],
}),
}
})
break
case 'team_created':
useTeamStore.getState().handleTeamCreated(msg.teamName)
break
@ -2546,6 +2588,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
slashCommands: [],
activeGoal: null,
backgroundAgentTasks: {},
stoppingBackgroundTaskIds: {},
agentTaskNotifications: {},
}))
clearPendingDelta(sessionId)
@ -2681,8 +2724,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
taskEvent.status === 'failed' ||
taskEvent.status === 'stopped') &&
shouldSuppressTaskNotificationResponse(session)
const stoppingBackgroundTaskIds = { ...session.stoppingBackgroundTaskIds }
delete stoppingBackgroundTaskIds[taskEvent.taskId]
return {
...buildBackgroundTaskSessionUpdate(session, backgroundAgentTasks, task, now),
stoppingBackgroundTaskIds,
...(suppressNotificationResponse ? { suppressNextTaskNotificationResponse: true } : {}),
agentTaskNotifications: {
...session.agentTaskNotifications,

View File

@ -26,6 +26,7 @@ export type ClientMessage =
| { type: 'set_permission_mode'; mode: PermissionMode }
| ({ type: 'set_runtime_config' } & RuntimeSelection)
| { type: 'stop_generation' }
| { type: 'stop_background_task'; taskId: string }
| { type: 'ping' }
export type AttachmentRef = {
@ -125,6 +126,7 @@ export type ServerMessage =
// 流式请求失败后的恢复状态:可能安全重试流,也可能降级为非流式请求。
| { type: 'streaming_fallback'; cause: StreamingFallbackCause }
| { type: 'error'; message: string; code: string; retryable?: boolean; businessErrorCode?: string }
| { type: 'background_task_stop_failed'; taskId: string; message: string }
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
| { type: 'pong' }
| { type: 'team_update'; teamName: string; members: TeamMemberStatus[] }

View File

@ -268,6 +268,60 @@ describe('WebSocket handler session isolation', () => {
})
})
it('forwards background task stop requests to the CLI control channel', async () => {
const sessionId = `stop-background-${crypto.randomUUID()}`
const ws = makeClientSocket(sessionId)
const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({})
handleWebSocket.message(ws, JSON.stringify({
type: 'stop_background_task',
taskId: 'bash-task-1',
}))
await Promise.resolve()
expect(requestControl).toHaveBeenCalledWith(sessionId, {
subtype: 'stop_task',
task_id: 'bash-task-1',
})
})
it('reports a task-scoped failure when the CLI rejects a background stop', async () => {
const sessionId = `stop-background-failed-${crypto.randomUUID()}`
const ws = makeClientSocket(sessionId)
spyOn(conversationService, 'requestControl').mockRejectedValue(new Error('Task is not running'))
handleWebSocket.message(ws, JSON.stringify({
type: 'stop_background_task',
taskId: 'bash-task-1',
}))
await Promise.resolve()
await Promise.resolve()
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'background_task_stop_failed',
taskId: 'bash-task-1',
message: 'Task is not running',
})
})
it('rejects malformed background task ids without throwing from the async handler', async () => {
const ws = makeClientSocket(`stop-background-invalid-${crypto.randomUUID()}`)
const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({})
handleWebSocket.message(ws, JSON.stringify({
type: 'stop_background_task',
taskId: 42,
}))
await Promise.resolve()
expect(requestControl).not.toHaveBeenCalled()
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'background_task_stop_failed',
taskId: '',
message: 'Background task id is required',
})
})
it('broadcasts tool and Computer Use permission resolutions to every client', () => {
const sessionId = `permission-resolution-${crypto.randomUUID()}`
const first = makeClientSocket(sessionId)

View File

@ -452,6 +452,23 @@ describe('WebSocket background task events', () => {
},
])
})
it('keeps AutoDream lifecycle visible without reviving foreground activity', () => {
const started = {
type: 'system',
subtype: 'task_started',
task_id: 'dream-task-1',
description: 'dreaming',
task_type: 'dream',
}
expect(translateCliMessage(started, 'session-1')).toEqual([{
type: 'system_notification',
subtype: 'task_started',
message: 'dreaming',
data: started,
}])
})
})
describe('WebSocket goal command events', () => {

View File

@ -29,6 +29,7 @@ export type ClientMessage =
| { type: 'set_permission_mode'; mode: string }
| { type: 'set_runtime_config'; providerId: string | null; modelId: string; effortLevel?: string }
| { type: 'stop_generation' }
| { type: 'stop_background_task'; taskId: string }
| { type: 'ping' }
export type AttachmentRef = {
@ -97,6 +98,7 @@ export type ServerMessage =
// 期间没有任何增量输出,前端据此显示"慢速模式"轻提示而不是裸转圈。
| { type: 'streaming_fallback'; cause: StreamingFallbackCause }
| { type: 'error'; message: string; code: string; retryable?: boolean; businessErrorCode?: string }
| { type: 'background_task_stop_failed'; taskId: string; message: string }
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
| { type: 'pong' }
| { type: 'team_update'; teamName: string; members: TeamMemberStatus[] }

View File

@ -297,6 +297,10 @@ export const handleWebSocket = {
handleStopGeneration(ws)
break
case 'stop_background_task':
void handleStopBackgroundTask(ws, message)
break
case 'ping':
ws.send(JSON.stringify({ type: 'pong' } satisfies ServerMessage))
break
@ -1007,6 +1011,36 @@ function handleStopGeneration(ws: ServerWebSocket<WebSocketData>) {
sendMessage(ws, { type: 'status', state: 'idle' })
}
async function handleStopBackgroundTask(
ws: ServerWebSocket<WebSocketData>,
message: Extract<ClientMessage, { type: 'stop_background_task' }>,
): Promise<void> {
const { sessionId } = ws.data
const taskId = typeof message.taskId === 'string' ? message.taskId.trim() : ''
if (!taskId) {
sendMessage(ws, {
type: 'background_task_stop_failed',
taskId,
message: 'Background task id is required',
})
return
}
try {
await conversationService.requestControl(sessionId, {
subtype: 'stop_task',
task_id: taskId,
})
} catch (error) {
sendMessage(ws, {
type: 'background_task_stop_failed',
taskId,
message: error instanceof Error ? error.message : String(error),
})
}
}
// ============================================================================
// Title generation
// ============================================================================
@ -1963,13 +1997,17 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
}]
}
if (subtype === 'task_started') {
const notification: ServerMessage = {
type: 'system_notification',
subtype: 'task_started',
message: cliMsg.message || cliMsg.description || 'Task started',
data: cliMsg,
}
// AutoDream is detached maintenance work. Keep it visible in Activity,
// but do not revive the already-completed foreground turn.
if (cliMsg.task_type === 'dream') return [notification]
return [
{
type: 'system_notification',
subtype: 'task_started',
message: cliMsg.message || cliMsg.description || 'Task started',
data: cliMsg,
},
notification,
{
type: 'status',
state: 'tool_executing',

View File

@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
resetStateForTests,
setIsInteractive,
switchSession,
} from '../bootstrap/state.js'
import type { AppState } from '../state/AppState.js'
import type { SessionId } from '../types/ids.js'
import { drainSdkEvents } from '../utils/sdkEventQueue.js'
import { stopTask } from './stopTask.js'
function makeShellTaskHarness() {
let killed = false
let state = {
tasks: {
btask123: {
id: 'btask123',
type: 'local_bash',
status: 'running',
description: 'Sleep for 300 seconds',
command: 'sleep 300',
toolUseId: 'bash-tool-1',
startTime: 1,
outputFile: '/tmp/btask123.output',
outputOffset: 0,
notified: false,
completionStatusSentInAttachment: false,
shellCommand: {
kill: () => {
killed = true
},
cleanup: () => {},
},
lastReportedTotalLines: 0,
isBackgrounded: true,
},
},
} as unknown as AppState
return {
get state() {
return state
},
get killed() {
return killed
},
setAppState(updater: (prev: AppState) => AppState) {
state = updater(state)
},
}
}
beforeEach(() => {
resetStateForTests()
setIsInteractive(false)
switchSession('stop-task-sdk-events' as SessionId)
drainSdkEvents()
})
afterEach(() => {
drainSdkEvents()
resetStateForTests()
})
describe('stopTask SDK events', () => {
test('emits a stopped bookend after LocalShellTask marks itself notified', async () => {
const harness = makeShellTaskHarness()
await stopTask('btask123', {
getAppState: () => harness.state,
setAppState: harness.setAppState,
})
expect(harness.killed).toBe(true)
expect(harness.state.tasks.btask123?.status).toBe('killed')
expect(drainSdkEvents()).toContainEqual(expect.objectContaining({
type: 'system',
subtype: 'task_notification',
task_id: 'btask123',
tool_use_id: 'bash-tool-1',
status: 'stopped',
summary: 'Sleep for 300 seconds',
session_id: 'stop-task-sdk-events',
}))
})
})

View File

@ -62,19 +62,22 @@ export async function stopTask(
)
}
// LocalShellTask.kill() atomically marks the task notified before returning.
// Capture the pre-kill state so the desktop SDK bookend is not suppressed by
// that implementation detail.
const shouldEmitShellTermination = isLocalShellTask(task) && !task.notified
await taskImpl.kill(taskId, setAppState)
// Bash: suppress the "exit code 137" notification (noise). Agent tasks: don't
// suppress — the AbortError catch sends a notification carrying
// extractPartialResult(agentMessages), which is the payload not noise.
if (isLocalShellTask(task)) {
let suppressed = false
setAppState(prev => {
const prevTask = prev.tasks[taskId]
if (!prevTask || prevTask.notified) {
return prev
}
suppressed = true
return {
...prev,
tasks: {
@ -86,7 +89,7 @@ export async function stopTask(
// Suppressing the XML notification also suppresses print.ts's parsed
// task_notification SDK event — emit it directly so SDK consumers see
// the task close.
if (suppressed) {
if (shouldEmitShellTermination) {
emitTaskTerminatedSdk(taskId, 'stopped', {
toolUseId: task.toolUseId,
summary: task.description,