From d6c156af1757287588ffd354395d7efe0bb047b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 11 Jul 2026 01:50:33 +0800 Subject: [PATCH] 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 --- .../activity/SessionActivityPanel.test.tsx | 88 ++++++++++++++ .../activity/SessionActivityPanel.tsx | 91 +++++++++++++-- desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/jp.ts | 2 + desktop/src/i18n/locales/kr.ts | 2 + desktop/src/i18n/locales/zh-TW.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + desktop/src/lib/backgroundTasks.test.ts | 31 +++++ desktop/src/lib/backgroundTasks.ts | 6 +- desktop/src/pages/ActiveSession.tsx | 10 ++ desktop/src/stores/chatStore.test.ts | 108 ++++++++++++++++++ desktop/src/stores/chatStore.ts | 46 ++++++++ desktop/src/types/chat.ts | 2 + .../__tests__/websocket-handler.test.ts | 54 +++++++++ src/server/__tests__/ws-memory-events.test.ts | 17 +++ src/server/ws/events.ts | 2 + src/server/ws/handler.ts | 50 +++++++- src/tasks/stopTask.test.ts | 86 ++++++++++++++ src/tasks/stopTask.ts | 9 +- 19 files changed, 592 insertions(+), 18 deletions(-) create mode 100644 desktop/src/lib/backgroundTasks.test.ts create mode 100644 src/tasks/stopTask.test.ts diff --git a/desktop/src/components/activity/SessionActivityPanel.test.tsx b/desktop/src/components/activity/SessionActivityPanel.test.tsx index 81bc6aee..9e86a988 100644 --- a/desktop/src/components/activity/SessionActivityPanel.test.tsx +++ b/desktop/src/components/activity/SessionActivityPanel.test.tsx @@ -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( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Stop background task Sleep for 300 seconds' })) + expect(onStopBackgroundTask).toHaveBeenCalledWith('bash-task-1') + + rerender( + , + ) + + 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( + , + ) + + 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( 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,