From 6c7bc27e9a5105826704f887e0a0abd2673031b6 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: Mon, 27 Jul 2026 00:51:43 +0800 Subject: [PATCH] fix(desktop): drop tasks deleted by TaskUpdate from the activity panel (#1101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TaskUpdate` treats `deleted` as a delete action, not a status — the CLI tool unlinks the task file and returns early. The activity panel read it as a status, and `normalizeTaskStatus` has no case for it, so it fell through to `pending`. The row then survived `mergeTaskRowsById`, which only overwrites rows the live list still knows about. A task the server had already deleted stayed pinned to the panel as pending, badge included. Reproduced end to end against a real provider (MiniMax-M3) on a throwaway Node project: the model reached for `TaskUpdate{taskId:'1',status:'deleted'}` on its own, `~/.claude/tasks//1.json` was gone afterwards, and `GET /api/tasks/lists/:id` returned only the two survivors — while the panel still rendered three rows with a badge of 1. Three places needed it: - Parsing `TaskUpdate` now removes the row instead of restating it. This also stops the panel inventing a `Task #N` row when the deletion has no matching `TaskCreate` in the same turn. - Deletions are collected across turns. A task created in one turn is often deleted in a later one, and per-turn parsing left the earlier row behind, which also skewed the "Earlier tasks" roll-up to `1 of 2 / stopped`. - `liveTasks` is filtered too. The task list only refreshes once the `tool_result` lands, so between the deletion and that round trip it still reports the deleted task. `normalizeTaskStatus` keeps its `pending` fallback — `deleted` is now caught upstream, and folding a delete action into the status enum is what caused this in the first place. Verified by replaying both real transcripts through the old and the new model side by side: the single-turn session goes from 3 rows/badge 1 to 2 rows/badge 0, and the two-turn session from `Task #1 pending` plus a `stopped` history row to just the one completed task. Four regression tests cover same-turn deletion, deletion without a matching create, cross-turn deletion, and a stale live list. `check:desktop` green at 3051 tests. --- .../activity/sessionActivityModel.test.ts | 227 ++++++++++++++++++ .../activity/sessionActivityModel.ts | 43 +++- 2 files changed, 267 insertions(+), 3 deletions(-) diff --git a/desktop/src/components/activity/sessionActivityModel.test.ts b/desktop/src/components/activity/sessionActivityModel.test.ts index 12b9d0e7..374a5f47 100644 --- a/desktop/src/components/activity/sessionActivityModel.test.ts +++ b/desktop/src/components/activity/sessionActivityModel.test.ts @@ -541,6 +541,233 @@ describe('buildSessionActivityModel', () => { expect(model.badgeCount).toBe(1) }) + it('drops tasks deleted by TaskUpdate instead of showing them as pending', () => { + const model = buildSessionActivityModel({ + sessionId: 'session-1', + messages: [ + { + id: 'task-create-1', + type: 'tool_use', + toolName: 'TaskCreate', + toolUseId: 'task-create-call-1', + input: { subject: '写 README' }, + timestamp: 1000, + }, + { + id: 'task-create-result-1', + type: 'tool_result', + toolUseId: 'task-create-call-1', + content: 'Task #1 created successfully: 写 README', + isError: false, + timestamp: 1001, + }, + { + id: 'task-create-2', + type: 'tool_use', + toolName: 'TaskCreate', + toolUseId: 'task-create-call-2', + input: { subject: '给 summarize 加空数组保护' }, + timestamp: 1002, + }, + { + id: 'task-create-result-2', + type: 'tool_result', + toolUseId: 'task-create-call-2', + content: 'Task #2 created successfully: 给 summarize 加空数组保护', + isError: false, + timestamp: 1003, + }, + { + id: 'task-update-1', + type: 'tool_use', + toolName: 'TaskUpdate', + toolUseId: 'task-update-call-1', + input: { taskId: '2', status: 'completed' }, + timestamp: 1004, + }, + { + id: 'task-update-2', + type: 'tool_use', + toolName: 'TaskUpdate', + toolUseId: 'task-update-call-2', + input: { taskId: '1', status: 'deleted' }, + timestamp: 1005, + }, + ], + tasks: [task({ id: '2', subject: '给 summarize 加空数组保护', status: 'completed' })], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows).toEqual([ + expect.objectContaining({ id: '2', label: '给 summarize 加空数组保护', status: 'completed' }), + ]) + expect(model.badgeCount).toBe(0) + }) + + it('does not invent a row for a TaskUpdate deletion without a matching TaskCreate', () => { + const model = buildSessionActivityModel({ + sessionId: 'session-1', + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '那条任务不用做了,删掉吧', + timestamp: 1000, + }, + { + id: 'task-update-1', + type: 'tool_use', + toolName: 'TaskUpdate', + toolUseId: 'task-update-call-1', + input: { taskId: '7', status: 'deleted' }, + timestamp: 1001, + }, + ], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows).toEqual([]) + expect(model.badgeCount).toBe(0) + }) + + it('removes tasks deleted in a later turn from earlier task history', () => { + const model = buildSessionActivityModel({ + sessionId: 'session-1', + messages: [ + { id: 'user-1', type: 'user_text', content: '先做订单功能', timestamp: 1000 }, + { + id: 'task-create-1', + type: 'tool_use', + toolName: 'TaskCreate', + toolUseId: 'task-create-call-1', + input: { subject: '实现订单汇总' }, + timestamp: 1001, + }, + { + id: 'task-create-result-1', + type: 'tool_result', + toolUseId: 'task-create-call-1', + content: 'Task #1 created successfully: 实现订单汇总', + isError: false, + timestamp: 1002, + }, + { + id: 'task-create-2', + type: 'tool_use', + toolName: 'TaskCreate', + toolUseId: 'task-create-call-2', + input: { subject: '写 README' }, + timestamp: 1003, + }, + { + id: 'task-create-result-2', + type: 'tool_result', + toolUseId: 'task-create-call-2', + content: 'Task #2 created successfully: 写 README', + isError: false, + timestamp: 1004, + }, + { + id: 'task-update-1', + type: 'tool_use', + toolName: 'TaskUpdate', + toolUseId: 'task-update-call-1', + input: { taskId: '1', status: 'completed' }, + timestamp: 1005, + }, + { id: 'user-2', type: 'user_text', content: 'README 不写了,继续做活动面板', timestamp: 2000 }, + { + id: 'task-update-2', + type: 'tool_use', + toolName: 'TaskUpdate', + toolUseId: 'task-update-call-2', + input: { taskId: '2', status: 'deleted' }, + timestamp: 2001, + }, + { + id: 'task-create-3', + type: 'tool_use', + toolName: 'TaskCreate', + toolUseId: 'task-create-call-3', + input: { subject: '实现活动面板' }, + timestamp: 2002, + }, + { + id: 'task-create-result-3', + type: 'tool_result', + toolUseId: 'task-create-call-3', + content: 'Task #3 created successfully: 实现活动面板', + isError: false, + timestamp: 2003, + }, + ], + tasks: [], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows).toEqual([ + expect.objectContaining({ id: '3', label: '实现活动面板', status: 'pending' }), + expect.objectContaining({ + label: 'Earlier tasks', + status: 'completed', + taskHistory: { completed: 1, total: 1, turnCount: 1 }, + }), + ]) + expect(model.badgeCount).toBe(1) + }) + + it('hides deleted tasks that a stale live task list still reports', () => { + const model = buildSessionActivityModel({ + sessionId: 'session-1', + messages: [ + { + id: 'task-update-1', + type: 'tool_use', + toolName: 'TaskUpdate', + toolUseId: 'task-update-call-1', + input: { taskId: '1', status: 'deleted' }, + timestamp: 1000, + }, + { + id: 'task-create-1', + type: 'tool_use', + toolName: 'TaskCreate', + toolUseId: 'task-create-call-1', + input: { subject: '补充边界测试' }, + timestamp: 1001, + }, + { + id: 'task-create-result-1', + type: 'tool_result', + toolUseId: 'task-create-call-1', + content: 'Task #2 created successfully: 补充边界测试', + isError: false, + timestamp: 1002, + }, + ], + // tool_result 后才异步 refreshTasks,这一刻的列表还没剔掉已删任务 + tasks: [ + task({ id: '1', subject: '写 README', status: 'pending' }), + task({ id: '2', subject: '补充边界测试', status: 'pending' }), + ], + completedAndDismissed: false, + backgroundTasks: [], + agentNotifications: [], + }) + + expect(model.sections.tasks.rows).toEqual([ + expect.objectContaining({ id: '2', label: '补充边界测试', status: 'pending' }), + ]) + expect(model.badgeCount).toBe(1) + }) + it('prefers task summary rows over earlier TodoWrite rows', () => { const model = buildSessionActivityModel({ sessionId: 'session-1', diff --git a/desktop/src/components/activity/sessionActivityModel.ts b/desktop/src/components/activity/sessionActivityModel.ts index 14eeb16b..0e2dcf5f 100644 --- a/desktop/src/components/activity/sessionActivityModel.ts +++ b/desktop/src/components/activity/sessionActivityModel.ts @@ -255,6 +255,34 @@ function normalizeTaskStatus(status: unknown): TaskSummaryItem['status'] { return 'pending' } +function taskIdFromInput(input: Record): string { + return stringField(input, 'taskId') || stringField(input, 'id') +} + +function isDeletedStatus(input: Record): boolean { + return stringField(input, 'status') === 'deleted' +} + +/** + * TaskUpdate 的 deleted 是删除动作而非状态,删除可能发生在创建它的那一轮之后, + * 所以要跨轮次收集,避免已删任务留在历史统计里。 + */ +function collectDeletedTaskIds(messages: UIMessage[]): Set { + const deletedTaskIds = new Set() + + for (const message of messages) { + if (message.type !== 'tool_use' || message.toolName !== 'TaskUpdate') continue + + const input = isRecordValue(message.input) ? message.input : {} + if (!isDeletedStatus(input)) continue + + const taskId = taskIdFromInput(input) + if (taskId) deletedTaskIds.add(taskId) + } + + return deletedTaskIds +} + function parseCreatedTaskResult(content: unknown): { id: string; subject?: string } | null { const text = extractTextContent(content) const match = text.match(/Task\s+#([^\s:]+)\s+created\s+successfully(?::\s*(.+))?/i) @@ -447,9 +475,15 @@ function buildTaskRowsFromTaskTools(messages: UIMessage[]): ActivityRow[] { if (message.toolName === 'TaskUpdate') { const input = isRecordValue(message.input) ? message.input : {} - const taskId = stringField(input, 'taskId') || stringField(input, 'id') + const taskId = taskIdFromInput(input) if (!taskId) continue + // deleted 不是一种任务状态:CLI 侧 TaskUpdateTool 会真的删掉任务文件 + if (isDeletedStatus(input)) { + rowsByTaskId.delete(taskId) + continue + } + const existing = rowsByTaskId.get(taskId) const activeForm = stringField(input, 'activeForm') const subject = stringField(input, 'subject') @@ -545,9 +579,12 @@ function buildHistoricalTasksRow(groups: TaskTurnRows[]): ActivityRow | null { } function buildTaskRowsFromMessages(messages: UIMessage[], liveTasks: CLITask[]): ActivityRow[] { - const liveRows = liveTasks.map(buildTaskRow) + const deletedTaskIds = collectDeletedTaskIds(messages) + const isLiveRow = (row: ActivityRow) => (row.taskId ? !deletedTaskIds.has(row.taskId) : true) + // 任务列表要等 tool_result 到达后才异步刷新,这中间 liveTasks 里还留着已删的任务 + const liveRows = liveTasks.filter((task) => !deletedTaskIds.has(task.id)).map(buildTaskRow) const taskTurnRows = splitMessagesIntoTurns(messages) - .map((turn) => ({ turn, rows: buildTaskRowsFromTurnMessages(turn.messages) })) + .map((turn) => ({ turn, rows: buildTaskRowsFromTurnMessages(turn.messages).filter(isLiveRow) })) .filter((group) => group.rows.length > 0) if (taskTurnRows.length === 0) {