From a1164dc49a5b580640891a2f5012728972fb3281 Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 26 May 2026 11:52:46 +0800 Subject: [PATCH] fix: harden task timeline model --- .../frontend/lib/task-timeline.test.ts | 68 ++++++++++++++++--- app-instance/frontend/lib/task-timeline.ts | 45 ++++++++++-- app-instance/frontend/types/index.ts | 11 ++- 3 files changed, 108 insertions(+), 16 deletions(-) diff --git a/app-instance/frontend/lib/task-timeline.test.ts b/app-instance/frontend/lib/task-timeline.test.ts index c95670e..f471b7c 100644 --- a/app-instance/frontend/lib/task-timeline.test.ts +++ b/app-instance/frontend/lib/task-timeline.test.ts @@ -25,10 +25,6 @@ function makeTask(overrides: Partial = {}): BackendTask { }; } -function eventKind(kind: string): ProcessEvent['kind'] { - return kind as ProcessEvent['kind']; -} - describe('buildTaskTimelineCards', () => { it('builds ordered timeline cards from task process data', () => { const task = makeTask(); @@ -63,7 +59,7 @@ describe('buildTaskTimelineCards', () => { event_id: 'evt-plan', run_id: 'run-main', parent_run_id: null, - kind: eventKind('task_planned'), + kind: 'task_planned', actor_type: 'agent', actor_id: 'main-agent', actor_name: 'Main Agent', @@ -74,7 +70,7 @@ describe('buildTaskTimelineCards', () => { event_id: 'evt-skill', run_id: 'run-main', parent_run_id: null, - kind: eventKind('skill_selected'), + kind: 'skill_selected', actor_type: 'system', actor_id: 'skill-router', actor_name: 'Skill Router', @@ -89,7 +85,7 @@ describe('buildTaskTimelineCards', () => { event_id: 'evt-tool-start', run_id: 'run-research', parent_run_id: 'run-main', - kind: eventKind('tool_call_started'), + kind: 'tool_call_started', actor_type: 'mcp', actor_id: 'document-reader', actor_name: 'Document Reader', @@ -100,7 +96,7 @@ describe('buildTaskTimelineCards', () => { event_id: 'evt-tool-finish', run_id: 'run-research', parent_run_id: 'run-main', - kind: eventKind('tool_call_finished'), + kind: 'tool_call_finished', actor_type: 'mcp', actor_id: 'document-reader', actor_name: 'Document Reader', @@ -169,4 +165,60 @@ describe('buildTaskTimelineCards', () => { expect(cards.at(-1)?.type).toBe('acceptance'); expect(cards.at(-1)?.summary).toContain('可以'); }); + + it('does not add fallback progress when a child run already has progress events', () => { + const task = makeTask(); + const processRuns: ProcessRun[] = [ + { + run_id: 'run-research', + parent_run_id: 'run-main', + session_id: 'web:default', + actor_type: 'agent', + actor_id: 'research-agent', + actor_name: 'Research Agent', + title: 'Read source documents', + status: 'running', + started_at: '2026-05-26T10:01:00.000Z', + }, + ]; + const processEvents: ProcessEvent[] = [ + { + event_id: 'evt-progress', + run_id: 'run-research', + parent_run_id: 'run-main', + kind: 'run_progress', + actor_type: 'agent', + actor_id: 'research-agent', + actor_name: 'Research Agent', + text: 'Reading source documents.', + created_at: '2026-05-26T10:02:00.000Z', + }, + ]; + + const cards = buildTaskTimelineCards({ task, processRuns, processEvents }); + + expect(cards.filter((card) => card.runId === 'run-research' && card.type === 'agent_progress')).toHaveLength(1); + expect(cards.map((card) => card.id)).not.toContain('run-research:fallback-progress'); + }); + + it('sorts invalid timestamps after valid timestamps while preserving insertion order', () => { + const task = makeTask(); + const processEvents: ProcessEvent[] = [ + { + event_id: 'evt-invalid-date', + run_id: 'run-main', + parent_run_id: null, + kind: 'task_planned', + actor_type: 'agent', + actor_id: 'main-agent', + actor_name: 'Main Agent', + text: 'Plan created.', + created_at: 'not-a-date', + }, + ]; + + const cards = buildTaskTimelineCards({ task, processEvents }); + + expect(cards.map((card) => card.id)).toEqual(['task-1:created', 'evt-invalid-date']); + }); }); diff --git a/app-instance/frontend/lib/task-timeline.ts b/app-instance/frontend/lib/task-timeline.ts index 5d90b1a..547d8ca 100644 --- a/app-instance/frontend/lib/task-timeline.ts +++ b/app-instance/frontend/lib/task-timeline.ts @@ -36,9 +36,9 @@ function isTimelineCardType(value: unknown): value is TaskTimelineCardType { return typeof value === 'string' && TIMELINE_CARD_TYPES.has(value as TaskTimelineCardType); } -function toTime(value: string): number { +function toTime(value: string): number | null { const parsed = new Date(value).getTime(); - return Number.isFinite(parsed) ? parsed : 0; + return Number.isFinite(parsed) ? parsed : null; } function firstString(...values: unknown[]): string | undefined { @@ -72,8 +72,9 @@ function normalizeSkillNames(metadata: Record | undefined): str } function cardTypeForEvent(event: ProcessEvent): TaskTimelineCardType | null { - if (isTimelineCardType(event.metadata?.timeline_type)) { - return event.metadata.timeline_type; + const timelineType = event.metadata?.timeline_type; + if (isTimelineCardType(timelineType)) { + return timelineType; } if (event.status === 'error') { @@ -184,12 +185,37 @@ function buildRunMap(processRuns: ProcessRun[]): Map { return map; } +function lastItem(items: T[]): T | null { + return items.length > 0 ? items[items.length - 1] : null; +} + +function compareCardsByCreatedAt( + a: { card: TaskTimelineCard; index: number }, + b: { card: TaskTimelineCard; index: number }, +): number { + const aTime = toTime(a.card.createdAt); + const bTime = toTime(b.card.createdAt); + + if (aTime === null && bTime === null) { + return a.index - b.index; + } + if (aTime === null) { + return 1; + } + if (bTime === null) { + return -1; + } + + return aTime - bTime || a.index - b.index; +} + export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): TaskTimelineCard[] { const { task } = input; const processRuns = input.processRuns ?? task.process_runs ?? []; const processEvents = input.processEvents ?? task.process_events ?? []; const processArtifacts = input.processArtifacts ?? task.process_artifacts ?? []; const runsById = buildRunMap(processRuns); + const runsWithProgressEvents = new Set(); const cards: TaskTimelineCard[] = [ { id: `${task.task_id}:created`, @@ -207,6 +233,9 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task for (const event of processEvents) { const type = cardTypeForEvent(event); if (!type) continue; + if (type === 'agent_progress') { + runsWithProgressEvents.add(event.run_id); + } cards.push({ id: event.event_id, @@ -225,6 +254,7 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task for (const run of processRuns) { if (!run.parent_run_id) continue; + if (runsWithProgressEvents.has(run.run_id)) continue; cards.push({ id: `${run.run_id}:fallback-progress`, @@ -266,7 +296,7 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task cards.push({ id: `${task.task_id}:result`, taskId: task.task_id, - runId: task.run_ids.at(-1) ?? null, + runId: lastItem(task.run_ids), type: 'result', title: titleForCard('result'), summary: resultSummary(task), @@ -276,7 +306,8 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task }); } - for (const [index, feedback] of task.feedback.entries()) { + for (let index = 0; index < task.feedback.length; index += 1) { + const feedback = task.feedback[index]; cards.push({ id: `${task.task_id}:acceptance:${index}`, taskId: task.task_id, @@ -292,6 +323,6 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task return cards .map((card, index) => ({ card, index })) - .sort((a, b) => toTime(a.card.createdAt) - toTime(b.card.createdAt) || a.index - b.index) + .sort(compareCardsByCreatedAt) .map(({ card }) => card); } diff --git a/app-instance/frontend/types/index.ts b/app-instance/frontend/types/index.ts index 33ef53b..7f52341 100644 --- a/app-instance/frontend/types/index.ts +++ b/app-instance/frontend/types/index.ts @@ -449,7 +449,16 @@ export type ProcessEventKind = | 'run_artifact' | 'run_status' | 'run_finished' - | 'run_cancelled'; + | 'run_cancelled' + | 'task_planned' + | 'skill_selected' + | 'tool_call_started' + | 'tool_call_finished' + | 'agent_team_created' + | 'agent_handoff' + | 'task_result_ready' + | 'task_acceptance_recorded' + | 'task_error'; export interface UiAgentDescriptor { id: string;