From 547f95a599f6cc545190037edbc07fd93e38f94a 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: Thu, 2 Jul 2026 21:00:18 +0800 Subject: [PATCH] fix(desktop): hide auto-dream task cards (#887) Fixes #887. Emit terminal SDK bookends for auto-dream completion, failure, and stop paths so the desktop task state can close instead of remaining running. Keep dream background task events out of the transcript while preserving lifecycle tracking in the background task drawer. Tested: bun test src/tasks/DreamTask/DreamTask.test.ts Tested: cd desktop && bun run test src/stores/chatStore.test.ts --run Tested: cd desktop && bun run test src/components/chat/MessageList.test.tsx --run -t auto-dream Tested: bun run check:server Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: full bun run verify / coverage gates; this is a scoped local issue fix. Confidence: high Scope-risk: narrow --- .../src/components/chat/MessageList.test.tsx | 27 +++++ desktop/src/components/chat/MessageList.tsx | 6 +- desktop/src/stores/chatStore.test.ts | 50 ++++++++ desktop/src/stores/chatStore.ts | 2 +- src/tasks/DreamTask/DreamTask.test.ts | 110 ++++++++++++++++++ src/tasks/DreamTask/DreamTask.ts | 54 ++++++--- 6 files changed, 233 insertions(+), 16 deletions(-) create mode 100644 src/tasks/DreamTask/DreamTask.test.ts diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index cb580da7..89fa169a 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -761,6 +761,33 @@ describe('MessageList nested tool calls', () => { expect(screen.queryByText('local_agent')).toBeNull() }) + it('does not render auto-dream background task events as separate transcript cards', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [{ + id: 'background-task-dream-hidden', + type: 'background_task', + timestamp: 2, + task: { + taskId: 'dream-task-hidden', + status: 'running', + taskType: 'dream', + description: 'dreaming', + startedAt: 1, + updatedAt: 2, + }, + }], + }), + }, + }) + + render() + + expect(screen.queryByTestId('background-task-event-card')).toBeNull() + expect(screen.queryByText('dreaming')).toBeNull() + }) + it('renders the historical window when scrolling away from latest', async () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index d4138a9c..a5b6a03b 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -387,7 +387,11 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent }) function isAgentBackgroundTaskMessage(message: UIMessage): boolean { if (message.type !== 'background_task') return false - if (message.task.taskType === 'local_agent' || message.task.taskType === 'remote_agent') { + if ( + message.task.taskType === 'local_agent' || + message.task.taskType === 'remote_agent' || + message.task.taskType === 'dream' + ) { return true } return /^Agent (?:(?:"[^"]+" )?(completed|was stopped)|(?:"[^"]+" )?failed(?::|$))/.test( diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 0d794e52..10af709a 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -2852,6 +2852,56 @@ describe('chatStore history mapping', () => { expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0) }) + it('keeps auto-dream background tasks out of the transcript while tracking lifecycle', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-06T00:00:01.000Z')) + + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession(), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'task_started', + data: { + task_id: 'dream-task-1', + task_type: 'dream', + description: 'dreaming', + }, + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['dream-task-1']).toMatchObject({ + taskId: 'dream-task-1', + status: 'running', + taskType: 'dream', + description: 'dreaming', + }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0) + + vi.setSystemTime(new Date('2026-04-06T00:00:02.000Z')) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'task_notification', + data: { + task_id: 'dream-task-1', + status: 'completed', + summary: 'Auto-dream completed', + }, + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['dream-task-1']).toMatchObject({ + status: 'completed', + taskType: 'dream', + summary: 'Auto-dream completed', + }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0) + + vi.useRealTimers() + }) + 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 f0d816d3..d0f5f8db 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -671,7 +671,7 @@ function mergeBackgroundTaskMessages( } function isAgentBackgroundTask(task: Pick): boolean { - if (task.taskType === 'local_agent' || task.taskType === 'remote_agent') { + if (task.taskType === 'local_agent' || task.taskType === 'remote_agent' || task.taskType === 'dream') { return true } return /^Agent (?:(?:"[^"]+" )?(completed|was stopped)|(?:"[^"]+" )?failed(?::|$))/.test( diff --git a/src/tasks/DreamTask/DreamTask.test.ts b/src/tasks/DreamTask/DreamTask.test.ts new file mode 100644 index 00000000..a10b7fdd --- /dev/null +++ b/src/tasks/DreamTask/DreamTask.test.ts @@ -0,0 +1,110 @@ +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 { + DreamTask, + completeDreamTask, + failDreamTask, + registerDreamTask, +} from './DreamTask.js' + +function makeTaskHarness() { + let state = { + tasks: {}, + } as AppState + + return { + get state() { + return state + }, + setAppState(updater: (prev: AppState) => AppState) { + state = updater(state) + }, + } +} + +beforeEach(() => { + resetStateForTests() + setIsInteractive(false) + switchSession('dream-task-sdk-events' as SessionId) + drainSdkEvents() +}) + +afterEach(() => { + drainSdkEvents() + resetStateForTests() +}) + +describe('DreamTask SDK events', () => { + test('emits a terminal SDK bookend when auto-dream completes', () => { + const harness = makeTaskHarness() + const taskId = registerDreamTask(harness.setAppState, { + sessionsReviewing: 5, + priorMtime: 0, + abortController: new AbortController(), + }) + drainSdkEvents() + + completeDreamTask(taskId, harness.setAppState) + + expect(drainSdkEvents()).toContainEqual(expect.objectContaining({ + type: 'system', + subtype: 'task_notification', + task_id: taskId, + status: 'completed', + summary: 'Auto-dream completed', + output_file: '', + session_id: 'dream-task-sdk-events', + })) + }) + + test('emits a terminal SDK bookend when auto-dream fails', () => { + const harness = makeTaskHarness() + const taskId = registerDreamTask(harness.setAppState, { + sessionsReviewing: 5, + priorMtime: 0, + abortController: new AbortController(), + }) + drainSdkEvents() + + failDreamTask(taskId, harness.setAppState) + + expect(drainSdkEvents()).toContainEqual(expect.objectContaining({ + type: 'system', + subtype: 'task_notification', + task_id: taskId, + status: 'failed', + summary: 'Auto-dream failed', + output_file: '', + session_id: 'dream-task-sdk-events', + })) + }) + + test('emits a terminal SDK bookend when auto-dream is stopped', async () => { + const harness = makeTaskHarness() + const taskId = registerDreamTask(harness.setAppState, { + sessionsReviewing: 5, + priorMtime: 0, + abortController: new AbortController(), + }) + drainSdkEvents() + + await DreamTask.kill(taskId, harness.setAppState) + + expect(drainSdkEvents()).toContainEqual(expect.objectContaining({ + type: 'system', + subtype: 'task_notification', + task_id: taskId, + status: 'stopped', + summary: 'Auto-dream stopped', + output_file: '', + session_id: 'dream-task-sdk-events', + })) + }) +}) diff --git a/src/tasks/DreamTask/DreamTask.ts b/src/tasks/DreamTask/DreamTask.ts index a732dcf7..ea01c025 100644 --- a/src/tasks/DreamTask/DreamTask.ts +++ b/src/tasks/DreamTask/DreamTask.ts @@ -7,6 +7,7 @@ import { rollbackConsolidationLock } from '../../services/autoDream/consolidatio import type { SetAppState, Task, TaskStateBase } from '../../Task.js' import { createTaskStateBase, generateTaskId } from '../../Task.js' import { registerTask, updateTaskState } from '../../utils/task/framework.js' +import { emitTaskTerminatedSdk } from '../../utils/sdkEventQueue.js' // Keep only the N most recent turns for live display. const MAX_TURNS = 30 @@ -107,26 +108,44 @@ export function completeDreamTask( taskId: string, setAppState: SetAppState, ): void { + let shouldEmit = false // notified: true immediately — dream has no model-facing notification path // (it's UI-only), and eviction requires terminal + notified. The inline // appendSystemMessage completion note IS the user surface. - updateTaskState(taskId, setAppState, task => ({ - ...task, - status: 'completed', - endTime: Date.now(), - notified: true, - abortController: undefined, - })) + updateTaskState(taskId, setAppState, task => { + shouldEmit = true + return { + ...task, + status: 'completed', + endTime: Date.now(), + notified: true, + abortController: undefined, + } + }) + if (shouldEmit) { + emitTaskTerminatedSdk(taskId, 'completed', { + summary: 'Auto-dream completed', + }) + } } export function failDreamTask(taskId: string, setAppState: SetAppState): void { - updateTaskState(taskId, setAppState, task => ({ - ...task, - status: 'failed', - endTime: Date.now(), - notified: true, - abortController: undefined, - })) + let shouldEmit = false + updateTaskState(taskId, setAppState, task => { + shouldEmit = true + return { + ...task, + status: 'failed', + endTime: Date.now(), + notified: true, + abortController: undefined, + } + }) + if (shouldEmit) { + emitTaskTerminatedSdk(taskId, 'failed', { + summary: 'Auto-dream failed', + }) + } } export const DreamTask: Task = { @@ -135,10 +154,12 @@ export const DreamTask: Task = { async kill(taskId, setAppState) { let priorMtime: number | undefined + let shouldEmit = false updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') return task task.abortController?.abort() priorMtime = task.priorMtime + shouldEmit = true return { ...task, status: 'killed', @@ -147,6 +168,11 @@ export const DreamTask: Task = { abortController: undefined, } }) + if (shouldEmit) { + emitTaskTerminatedSdk(taskId, 'stopped', { + summary: 'Auto-dream stopped', + }) + } // Rewind the lock mtime so the next session can retry. Same path as the // fork-failure catch in autoDream.ts. If updateTaskState was a no-op // (already terminal), priorMtime stays undefined and we skip.