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.