mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
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:
parent
eecc873535
commit
6c7bc27e9a
@ -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',
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user