fix(desktop): drop tasks deleted by TaskUpdate from the activity panel (#1101)

`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/<session>/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.
This commit is contained in:
程序员阿江(Relakkes) 2026-07-27 00:51:43 +08:00
parent eecc873535
commit 6c7bc27e9a
2 changed files with 267 additions and 3 deletions

View File

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

View File

@ -255,6 +255,34 @@ function normalizeTaskStatus(status: unknown): TaskSummaryItem['status'] {
return 'pending'
}
function taskIdFromInput(input: Record<string, unknown>): string {
return stringField(input, 'taskId') || stringField(input, 'id')
}
function isDeletedStatus(input: Record<string, unknown>): boolean {
return stringField(input, 'status') === 'deleted'
}
/**
* TaskUpdate deleted
*
*/
function collectDeletedTaskIds(messages: UIMessage[]): Set<string> {
const deletedTaskIds = new Set<string>()
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) {