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
This commit is contained in:
程序员阿江(Relakkes) 2026-07-02 21:00:18 +08:00
parent 2228524462
commit 547f95a599
6 changed files with 233 additions and 16 deletions

View File

@ -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(<MessageList />)
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: {

View File

@ -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(

View File

@ -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()

View File

@ -671,7 +671,7 @@ function mergeBackgroundTaskMessages(
}
function isAgentBackgroundTask(task: Pick<BackgroundAgentTask, 'taskType' | 'summary'>): 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(

View File

@ -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',
}))
})
})

View File

@ -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<DreamTaskState>(taskId, setAppState, task => ({
...task,
status: 'completed',
endTime: Date.now(),
notified: true,
abortController: undefined,
}))
updateTaskState<DreamTaskState>(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<DreamTaskState>(taskId, setAppState, task => ({
...task,
status: 'failed',
endTime: Date.now(),
notified: true,
abortController: undefined,
}))
let shouldEmit = false
updateTaskState<DreamTaskState>(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<DreamTaskState>(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.