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 (
+
+ )
+}
+
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 ? (
+
+ ) : 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}
@@ -329,31 +374,55 @@ function ActivityRowView({
if (row.section === 'subagents' && row.openable && row.toolUseId) {
const statusLabel = getActivityStatusLabel(row.status, t)
-
- return (
+ const openButton = (
)
+
+ return stopButton ? (
+
+ {openButton}
+ {stopButton}
+
+ ) : openButton
}
if (row.section === 'backgroundTasks' && onOpenBackgroundTask && hasBackgroundTaskDetails(row)) {
- return (
+ const openButton = (
)
+
+ return stopButton ? (
+
+ {openButton}
+ {stopButton}
+
+ ) : openButton
+ }
+
+ if (stopButton) {
+ return (
+
+
+ {content}
+
+ {stopButton}
+
+ )
}
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
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
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index 0748c757..334d22d7 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -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',
diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts
index 518012f6..c22945bc 100644
--- a/desktop/src/i18n/locales/jp.ts
+++ b/desktop/src/i18n/locales/jp.ts
@@ -1701,6 +1701,8 @@ export const jp: Record = {
'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': '説明',
diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts
index 5dd77dbb..966d8e91 100644
--- a/desktop/src/i18n/locales/kr.ts
+++ b/desktop/src/i18n/locales/kr.ts
@@ -1701,6 +1701,8 @@ export const kr: Record = {
'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': '설명',
diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts
index 0d647bef..321b96fc 100644
--- a/desktop/src/i18n/locales/zh-TW.ts
+++ b/desktop/src/i18n/locales/zh-TW.ts
@@ -1701,6 +1701,8 @@ export const zh: Record = {
'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': '描述',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 1f601d58..8fe27176 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -1701,6 +1701,8 @@ export const zh: Record = {
'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': '描述',
diff --git a/desktop/src/lib/backgroundTasks.test.ts b/desktop/src/lib/backgroundTasks.test.ts
new file mode 100644
index 00000000..22c36b49
--- /dev/null
+++ b/desktop/src/lib/backgroundTasks.test.ts
@@ -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 {
+ 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)
+ })
+})
diff --git a/desktop/src/lib/backgroundTasks.ts b/desktop/src/lib/backgroundTasks.ts
index b5bd834f..18ee3eec 100644
--- a/desktop/src/lib/backgroundTasks.ts
+++ b/desktop/src/lib/backgroundTasks.ts
@@ -4,7 +4,11 @@ import type { TranslationKey } from '../i18n'
type Translator = (key: TranslationKey, params?: Record) => string
export function hasRunningBackgroundTasks(tasks?: Record): 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 {
diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx
index 7c84e1b3..4262e259 100644
--- a/desktop/src/pages/ActiveSession.tsx
+++ b/desktop/src/pages/ActiveSession.tsx
@@ -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}
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts
index 32eecfc8..0692edec 100644
--- a/desktop/src/stores/chatStore.test.ts
+++ b/desktop/src/stores/chatStore.test.ts
@@ -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()
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index 87181e3f..1a3e54ec 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -127,6 +127,7 @@ export type PerSessionState = {
slashCommands: Array<{ name: string; description: string; argumentHint?: string }>
agentTaskNotifications: Record
backgroundAgentTasks?: Record
+ stoppingBackgroundTaskIds?: Record
suppressNextTaskNotificationResponse?: boolean
activeGoal?: ActiveGoalState | null
elapsedTimer: ReturnType | 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
reloadHistory: (
sessionId: string,
@@ -1345,6 +1348,22 @@ export const useChatStore = create((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((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((set, get) => ({
slashCommands: [],
activeGoal: null,
backgroundAgentTasks: {},
+ stoppingBackgroundTaskIds: {},
agentTaskNotifications: {},
}))
clearPendingDelta(sessionId)
@@ -2681,8 +2724,11 @@ export const useChatStore = create((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,
diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts
index 2768e6ae..94bb21ee 100644
--- a/desktop/src/types/chat.ts
+++ b/desktop/src/types/chat.ts
@@ -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[] }
diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts
index 1a354499..ce79770a 100644
--- a/src/server/__tests__/websocket-handler.test.ts
+++ b/src/server/__tests__/websocket-handler.test.ts
@@ -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)
diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts
index 7a3fc7ed..0011679a 100644
--- a/src/server/__tests__/ws-memory-events.test.ts
+++ b/src/server/__tests__/ws-memory-events.test.ts
@@ -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', () => {
diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts
index 3f1a7171..ae405eac 100644
--- a/src/server/ws/events.ts
+++ b/src/server/ws/events.ts
@@ -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[] }
diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts
index 351eaf9d..6e951d54 100644
--- a/src/server/ws/handler.ts
+++ b/src/server/ws/handler.ts
@@ -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) {
sendMessage(ws, { type: 'status', state: 'idle' })
}
+async function handleStopBackgroundTask(
+ ws: ServerWebSocket,
+ message: Extract,
+): Promise {
+ 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',
diff --git a/src/tasks/stopTask.test.ts b/src/tasks/stopTask.test.ts
new file mode 100644
index 00000000..4cd1a83f
--- /dev/null
+++ b/src/tasks/stopTask.test.ts
@@ -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',
+ }))
+ })
+})
diff --git a/src/tasks/stopTask.ts b/src/tasks/stopTask.ts
index c2cd768f..d5772829 100644
--- a/src/tasks/stopTask.ts
+++ b/src/tasks/stopTask.ts
@@ -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,