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.