feat: add task timeline model
This commit is contained in:
172
app-instance/frontend/lib/task-timeline.test.ts
Normal file
172
app-instance/frontend/lib/task-timeline.test.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||||
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
function makeTask(overrides: Partial<BackendTask> = {}): BackendTask {
|
||||
return {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:default',
|
||||
description: 'Research the market',
|
||||
short_title: 'Market research',
|
||||
is_open: true,
|
||||
goal: 'Summarize the market',
|
||||
constraints: [],
|
||||
priority: 1,
|
||||
status: 'running',
|
||||
creator: 'user',
|
||||
created_at: '2026-05-26T10:00:00.000Z',
|
||||
updated_at: '2026-05-26T10:00:00.000Z',
|
||||
run_ids: ['run-main'],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
title: 'Plan and coordinate',
|
||||
status: 'running',
|
||||
started_at: '2026-05-26T10:00:30.000Z',
|
||||
},
|
||||
{
|
||||
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: 'done',
|
||||
started_at: '2026-05-26T10:05:00.000Z',
|
||||
finished_at: '2026-05-26T10:05:30.000Z',
|
||||
summary: 'Finished reading source documents.',
|
||||
},
|
||||
];
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-plan',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: eventKind('task_planned'),
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main-agent',
|
||||
actor_name: 'Main Agent',
|
||||
text: 'Plan created.',
|
||||
created_at: '2026-05-26T10:01:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-skill',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: eventKind('skill_selected'),
|
||||
actor_type: 'system',
|
||||
actor_id: 'skill-router',
|
||||
actor_name: 'Skill Router',
|
||||
text: 'Research skill selected.',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
metadata: {
|
||||
selected_skill_names: ['research'],
|
||||
reason: 'Need source review.',
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-start',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: eventKind('tool_call_started'),
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'document-reader',
|
||||
actor_name: 'Document Reader',
|
||||
text: 'Reading source documents.',
|
||||
created_at: '2026-05-26T10:03:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-finish',
|
||||
run_id: 'run-research',
|
||||
parent_run_id: 'run-main',
|
||||
kind: eventKind('tool_call_finished'),
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'document-reader',
|
||||
actor_name: 'Document Reader',
|
||||
text: 'Documents read.',
|
||||
created_at: '2026-05-26T10:04:00.000Z',
|
||||
metadata: {
|
||||
result_summary: '2 documents read successfully.',
|
||||
},
|
||||
},
|
||||
];
|
||||
const processArtifacts: ProcessArtifact[] = [
|
||||
{
|
||||
artifact_id: 'artifact-summary',
|
||||
run_id: 'run-research',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'research-agent',
|
||||
actor_name: 'Research Agent',
|
||||
title: 'Research summary',
|
||||
artifact_type: 'markdown',
|
||||
content: '# Summary',
|
||||
created_at: '2026-05-26T10:06:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({
|
||||
task,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
});
|
||||
|
||||
expect(cards.map((card) => card.type)).toEqual([
|
||||
'task_created',
|
||||
'plan',
|
||||
'skill',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'agent_progress',
|
||||
'artifact',
|
||||
]);
|
||||
expect(cards[1].title).toBe('执行计划');
|
||||
expect(cards[2].title).toBe('选择 Skill');
|
||||
expect(cards[4].summary).toBe('2 documents read successfully.');
|
||||
expect(cards[6].relatedArtifactIds).toEqual(['artifact-summary']);
|
||||
});
|
||||
|
||||
it('appends result and acceptance cards for closed tasks with feedback', () => {
|
||||
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 cards = buildTaskTimelineCards({ task });
|
||||
|
||||
expect(cards.at(-2)?.type).toBe('result');
|
||||
expect(cards.at(-1)?.type).toBe('acceptance');
|
||||
expect(cards.at(-1)?.summary).toContain('可以');
|
||||
});
|
||||
});
|
||||
297
app-instance/frontend/lib/task-timeline.ts
Normal file
297
app-instance/frontend/lib/task-timeline.ts
Normal file
@ -0,0 +1,297 @@
|
||||
import type {
|
||||
BackendTask,
|
||||
ProcessArtifact,
|
||||
ProcessEvent,
|
||||
ProcessRun,
|
||||
TaskTimelineCard,
|
||||
TaskTimelineCardType,
|
||||
} from '@/types';
|
||||
|
||||
export type BuildTaskTimelineCardsInput = {
|
||||
task: BackendTask;
|
||||
processRuns?: ProcessRun[];
|
||||
processEvents?: ProcessEvent[];
|
||||
processArtifacts?: ProcessArtifact[];
|
||||
};
|
||||
|
||||
const TIMELINE_CARD_TYPES = new Set<TaskTimelineCardType>([
|
||||
'task_created',
|
||||
'plan',
|
||||
'skill',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'next_step',
|
||||
'agent_team',
|
||||
'agent_progress',
|
||||
'agent_handoff',
|
||||
'artifact',
|
||||
'error',
|
||||
'result',
|
||||
'acceptance',
|
||||
]);
|
||||
|
||||
const RESULT_STATUSES = new Set(['awaiting_acceptance', 'closed', 'abandoned', 'cancelled', 'error']);
|
||||
|
||||
function isTimelineCardType(value: unknown): value is TaskTimelineCardType {
|
||||
return typeof value === 'string' && TIMELINE_CARD_TYPES.has(value as TaskTimelineCardType);
|
||||
}
|
||||
|
||||
function toTime(value: string): number {
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return [value.trim()];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeSkillNames(metadata: Record<string, unknown> | undefined): string[] | undefined {
|
||||
if (!metadata || (!('skill_names' in metadata) && !('selected_skill_names' in metadata))) {
|
||||
return undefined;
|
||||
}
|
||||
const names = [
|
||||
...stringList(metadata.skill_names),
|
||||
...stringList(metadata.selected_skill_names),
|
||||
];
|
||||
return Array.from(new Set(names));
|
||||
}
|
||||
|
||||
function cardTypeForEvent(event: ProcessEvent): TaskTimelineCardType | null {
|
||||
if (isTimelineCardType(event.metadata?.timeline_type)) {
|
||||
return event.metadata.timeline_type;
|
||||
}
|
||||
|
||||
if (event.status === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
switch (String(event.kind)) {
|
||||
case 'task_planned':
|
||||
case 'run_started':
|
||||
return 'plan';
|
||||
case 'skill_selected':
|
||||
return 'skill';
|
||||
case 'tool_call_started':
|
||||
return 'tool_call';
|
||||
case 'tool_call_finished':
|
||||
return 'tool_result';
|
||||
case 'agent_team_created':
|
||||
return 'agent_team';
|
||||
case 'agent_handoff':
|
||||
return 'agent_handoff';
|
||||
case 'run_progress':
|
||||
case 'run_finished':
|
||||
return 'agent_progress';
|
||||
case 'task_result_ready':
|
||||
return 'result';
|
||||
case 'task_acceptance_recorded':
|
||||
return 'acceptance';
|
||||
case 'task_error':
|
||||
return 'error';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function titleForCard(type: TaskTimelineCardType, actorName?: string): string {
|
||||
switch (type) {
|
||||
case 'task_created':
|
||||
return '任务已创建';
|
||||
case 'plan':
|
||||
return '执行计划';
|
||||
case 'skill':
|
||||
return '选择 Skill';
|
||||
case 'tool_call':
|
||||
return actorName ? `调用工具:${actorName}` : '调用工具';
|
||||
case 'tool_result':
|
||||
return actorName ? `工具结果:${actorName}` : '工具结果';
|
||||
case 'next_step':
|
||||
return '下一步';
|
||||
case 'agent_team':
|
||||
return '启动 Agent Team';
|
||||
case 'agent_progress':
|
||||
return actorName || 'Agent 进展';
|
||||
case 'agent_handoff':
|
||||
return 'Agent 交接';
|
||||
case 'artifact':
|
||||
return '生成产物';
|
||||
case 'error':
|
||||
return '执行遇到问题';
|
||||
case 'result':
|
||||
return '本轮结果';
|
||||
case 'acceptance':
|
||||
return '任务验收';
|
||||
}
|
||||
}
|
||||
|
||||
function summaryForEvent(event: ProcessEvent): string | undefined {
|
||||
return firstString(
|
||||
event.metadata?.result_summary,
|
||||
event.metadata?.reason,
|
||||
event.metadata?.action_summary,
|
||||
event.text,
|
||||
);
|
||||
}
|
||||
|
||||
function detailsForEvent(event: ProcessEvent): Record<string, unknown> | undefined {
|
||||
const skillNames = normalizeSkillNames(event.metadata);
|
||||
if (!event.metadata && !skillNames) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(event.metadata ?? {}),
|
||||
...(skillNames ? { skill_names: skillNames } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function feedbackCreatedAt(feedback: Record<string, unknown>, task: BackendTask): string {
|
||||
return firstString(feedback.created_at, task.updated_at, task.created_at) ?? task.created_at;
|
||||
}
|
||||
|
||||
function feedbackSummary(feedback: Record<string, unknown>): string | undefined {
|
||||
return firstString(feedback.comment, feedback.summary, feedback.acceptance_type);
|
||||
}
|
||||
|
||||
function resultSummary(task: BackendTask): string | undefined {
|
||||
return firstString(
|
||||
task.metadata?.result_summary,
|
||||
task.metadata?.summary,
|
||||
task.close_reason,
|
||||
task.validation_result?.summary,
|
||||
);
|
||||
}
|
||||
|
||||
function buildRunMap(processRuns: ProcessRun[]): Map<string, ProcessRun> {
|
||||
const map = new Map<string, ProcessRun>();
|
||||
for (const run of processRuns) {
|
||||
map.set(run.run_id, run);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
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 cards: TaskTimelineCard[] = [
|
||||
{
|
||||
id: `${task.task_id}:created`,
|
||||
taskId: task.task_id,
|
||||
type: 'task_created',
|
||||
title: titleForCard('task_created'),
|
||||
summary: firstString(task.short_title, task.description, task.goal),
|
||||
actorName: task.creator,
|
||||
status: task.status,
|
||||
createdAt: task.created_at,
|
||||
details: task.metadata,
|
||||
},
|
||||
];
|
||||
|
||||
for (const event of processEvents) {
|
||||
const type = cardTypeForEvent(event);
|
||||
if (!type) continue;
|
||||
|
||||
cards.push({
|
||||
id: event.event_id,
|
||||
taskId: task.task_id,
|
||||
runId: event.run_id,
|
||||
parentRunId: event.parent_run_id,
|
||||
type,
|
||||
title: titleForCard(type, event.actor_name),
|
||||
summary: summaryForEvent(event),
|
||||
actorName: event.actor_name,
|
||||
status: event.status,
|
||||
createdAt: event.created_at,
|
||||
details: detailsForEvent(event),
|
||||
});
|
||||
}
|
||||
|
||||
for (const run of processRuns) {
|
||||
if (!run.parent_run_id) continue;
|
||||
|
||||
cards.push({
|
||||
id: `${run.run_id}:fallback-progress`,
|
||||
taskId: task.task_id,
|
||||
runId: run.run_id,
|
||||
parentRunId: run.parent_run_id,
|
||||
type: 'agent_progress',
|
||||
title: titleForCard('agent_progress', run.actor_name),
|
||||
summary: firstString(run.summary, run.title),
|
||||
actorName: run.actor_name,
|
||||
status: run.status,
|
||||
createdAt: run.started_at,
|
||||
details: run.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
for (const artifact of processArtifacts) {
|
||||
const run = runsById.get(artifact.run_id);
|
||||
cards.push({
|
||||
id: artifact.artifact_id,
|
||||
taskId: task.task_id,
|
||||
runId: artifact.run_id,
|
||||
parentRunId: run?.parent_run_id,
|
||||
type: 'artifact',
|
||||
title: titleForCard('artifact'),
|
||||
summary: firstString(artifact.title),
|
||||
actorName: artifact.actor_name,
|
||||
createdAt: artifact.created_at,
|
||||
relatedArtifactIds: [artifact.artifact_id],
|
||||
details: {
|
||||
...(artifact.metadata ?? {}),
|
||||
artifact_type: artifact.artifact_type,
|
||||
title: artifact.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (RESULT_STATUSES.has(task.status)) {
|
||||
cards.push({
|
||||
id: `${task.task_id}:result`,
|
||||
taskId: task.task_id,
|
||||
runId: task.run_ids.at(-1) ?? null,
|
||||
type: 'result',
|
||||
title: titleForCard('result'),
|
||||
summary: resultSummary(task),
|
||||
status: task.status,
|
||||
createdAt: task.closed_at ?? task.updated_at ?? task.created_at,
|
||||
details: task.validation_result ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, feedback] of task.feedback.entries()) {
|
||||
cards.push({
|
||||
id: `${task.task_id}:acceptance:${index}`,
|
||||
taskId: task.task_id,
|
||||
runId: firstString(feedback.run_id) ?? null,
|
||||
type: 'acceptance',
|
||||
title: titleForCard('acceptance'),
|
||||
summary: feedbackSummary(feedback),
|
||||
status: firstString(feedback.acceptance_type),
|
||||
createdAt: feedbackCreatedAt(feedback, task),
|
||||
details: feedback,
|
||||
});
|
||||
}
|
||||
|
||||
return cards
|
||||
.map((card, index) => ({ card, index }))
|
||||
.sort((a, b) => toTime(a.card.createdAt) - toTime(b.card.createdAt) || a.index - b.index)
|
||||
.map(({ card }) => card);
|
||||
}
|
||||
@ -771,6 +771,36 @@ export interface ProcessArtifact {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type TaskTimelineCardType =
|
||||
| 'task_created'
|
||||
| 'plan'
|
||||
| 'skill'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'next_step'
|
||||
| 'agent_team'
|
||||
| 'agent_progress'
|
||||
| 'agent_handoff'
|
||||
| 'artifact'
|
||||
| 'error'
|
||||
| 'result'
|
||||
| 'acceptance';
|
||||
|
||||
export interface TaskTimelineCard {
|
||||
id: string;
|
||||
taskId: string;
|
||||
runId?: string | null;
|
||||
parentRunId?: string | null;
|
||||
type: TaskTimelineCardType;
|
||||
title: string;
|
||||
summary?: string;
|
||||
actorName?: string;
|
||||
status?: string;
|
||||
createdAt: string;
|
||||
relatedArtifactIds?: string[];
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SessionProcessProjection {
|
||||
runs: ProcessRun[];
|
||||
events: ProcessEvent[];
|
||||
|
||||
Reference in New Issue
Block a user