From 2e4f8541ee17f93830b11b5dde6d08148576506b Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 26 May 2026 11:59:49 +0800 Subject: [PATCH] fix: dedupe task timeline milestones --- .../frontend/lib/task-timeline.test.ts | 48 +++++++++++++++++++ app-instance/frontend/lib/task-timeline.ts | 22 +++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/app-instance/frontend/lib/task-timeline.test.ts b/app-instance/frontend/lib/task-timeline.test.ts index f471b7c..0161dd1 100644 --- a/app-instance/frontend/lib/task-timeline.test.ts +++ b/app-instance/frontend/lib/task-timeline.test.ts @@ -221,4 +221,52 @@ describe('buildTaskTimelineCards', () => { expect(cards.map((card) => card.id)).toEqual(['task-1:created', 'evt-invalid-date']); }); + + it('dedupes synthetic result and acceptance milestones when lifecycle events exist', () => { + const task = makeTask({ + is_open: false, + status: 'closed', + updated_at: '2026-05-26T10:04:00.000Z', + closed_at: '2026-05-26T10:04:00.000Z', + feedback: [ + { + acceptance_type: 'accept', + comment: '可以', + created_at: '2026-05-26T10:05:00.000Z', + run_id: 'run-main', + }, + ], + }); + const processEvents: ProcessEvent[] = [ + { + event_id: 'evt-result-ready', + run_id: 'run-main', + parent_run_id: null, + kind: 'task_result_ready', + actor_type: 'agent', + actor_id: 'main-agent', + actor_name: 'Main Agent', + text: 'Result is ready.', + created_at: '2026-05-26T10:04:00.000Z', + }, + { + event_id: 'evt-acceptance-recorded', + run_id: 'run-main', + parent_run_id: null, + kind: 'task_acceptance_recorded', + actor_type: 'system', + actor_id: 'task-system', + actor_name: 'Task System', + text: '可以', + created_at: '2026-05-26T10:05:00.000Z', + }, + ]; + + const cards = buildTaskTimelineCards({ task, processEvents }); + + expect(cards.filter((card) => card.type === 'result')).toHaveLength(1); + expect(cards.filter((card) => card.type === 'acceptance')).toHaveLength(1); + expect(cards.map((card) => card.id)).toContain('evt-result-ready'); + expect(cards.map((card) => card.id)).toContain('evt-acceptance-recorded'); + }); }); diff --git a/app-instance/frontend/lib/task-timeline.ts b/app-instance/frontend/lib/task-timeline.ts index 547d8ca..2d75620 100644 --- a/app-instance/frontend/lib/task-timeline.ts +++ b/app-instance/frontend/lib/task-timeline.ts @@ -209,6 +209,10 @@ function compareCardsByCreatedAt( return aTime - bTime || a.index - b.index; } +function acceptanceKey(runId: string | null | undefined, createdAt: string): string { + return `${runId ?? ''}:${createdAt}`; +} + export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): TaskTimelineCard[] { const { task } = input; const processRuns = input.processRuns ?? task.process_runs ?? []; @@ -216,6 +220,8 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task const processArtifacts = input.processArtifacts ?? task.process_artifacts ?? []; const runsById = buildRunMap(processRuns); const runsWithProgressEvents = new Set(); + const acceptanceEventKeys = new Set(); + let hasResultEventCard = false; const cards: TaskTimelineCard[] = [ { id: `${task.task_id}:created`, @@ -236,6 +242,12 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task if (type === 'agent_progress') { runsWithProgressEvents.add(event.run_id); } + if (type === 'result') { + hasResultEventCard = true; + } + if (type === 'acceptance') { + acceptanceEventKeys.add(acceptanceKey(event.run_id, event.created_at)); + } cards.push({ id: event.event_id, @@ -292,7 +304,7 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task }); } - if (RESULT_STATUSES.has(task.status)) { + if (RESULT_STATUSES.has(task.status) && !hasResultEventCard) { cards.push({ id: `${task.task_id}:result`, taskId: task.task_id, @@ -308,15 +320,19 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task for (let index = 0; index < task.feedback.length; index += 1) { const feedback = task.feedback[index]; + const runId = firstString(feedback.run_id) ?? null; + const createdAt = feedbackCreatedAt(feedback, task); + if (acceptanceEventKeys.has(acceptanceKey(runId, createdAt))) continue; + cards.push({ id: `${task.task_id}:acceptance:${index}`, taskId: task.task_id, - runId: firstString(feedback.run_id) ?? null, + runId, type: 'acceptance', title: titleForCard('acceptance'), summary: feedbackSummary(feedback), status: firstString(feedback.acceptance_type), - createdAt: feedbackCreatedAt(feedback, task), + createdAt, details: feedback, }); }