mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
2228524462
commit
547f95a599
@ -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: {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
110
src/tasks/DreamTask/DreamTask.test.ts
Normal file
110
src/tasks/DreamTask/DreamTask.test.ts
Normal 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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user