Files
Agent-System/web/src/engine/phaseEventMapper.ts
T
weichenandCursor 725cad3d25 fix(web): 任务状态卡收口与渲染层归一化冻结进度文案
与后端 fix(taskrun) 对称:

- phaseEventMapper.settleStep:applyPhaseEvent 收口分支(CLOSE_PHASES、
  新 phase 结算前序、existing 条目刷新)把 completed 条目 label 归一化为
  短标签 + "已完成",保留"(第 x/y 步)"结构后缀;failed 有意保留原文案。
  导出 PHASE_META 供展示层复用。
- lib/taskRunCard.displayStepLabel:渲染层兜底清洗历史脏快照(如 #4424),
  回填失败/漏跑时前端仍正确展示。
- 更新 phaseEventMapper.test.ts 中两处此前把 bug 钉死为预期的断言;
  TaskRunCard.test.ts 新增复现 #4424 展示层兜底用例。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 10:32:57 +00:00

121 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PhaseEvent } from '../types/workbench';
/** 后端 phase 事件载荷(与 StreamEvent['phase'] 结构兼容,保持 mapper 独立可测) */
export type PhaseEventPayload = {
phase_id?: string;
message?: string;
role?: string;
step_index?: number;
total_steps?: number;
progress?: number;
};
/** phase_id → 图标/短标签映射(与 bizdev-cli/main.go 的 CLI 映射保持一致) */
export const PHASE_META: Record<string, { icon: string; label: string }> = {
squad_assembly: { icon: '👥', label: '组建小队' },
knowledge_retrieval: { icon: '📚', label: '知识检索' },
knowledge_retrieved: { icon: '📚', label: '知识命中' },
step_executing: { icon: '⚙️', label: '专员执行' },
step_completed: { icon: '✅', label: '步骤完成' },
quality_gate: { icon: '🛡️', label: '质量门禁' },
advisor_briefing: { icon: '💡', label: '参谋简报' },
stage_transition: { icon: '🚀', label: '阶段跃迁' },
stage_started: { icon: '🚀', label: '阶段启动' },
stage_completed: { icon: '🎉', label: '阶段完成' },
step_failed: { icon: '⚠️', label: '步骤异常' },
task_failed: { icon: '⚠️', label: '任务异常' },
};
/** 收口型 phase:不追加新节点,仅把当前 running 条目置为 completed */
const CLOSE_PHASES = new Set(['step_completed', 'stage_completed']);
/** 从 step key 还原 phase_id(去掉 "#<idx>" 后缀) */
function phaseIdOfStep(step: string): string {
const idx = step.indexOf('#');
return idx >= 0 ? step.slice(0, idx) : step;
}
/**
* settleStep 把处于 completed 终态的条目标签归一化为静态措辞(短标签 + "已完成"),
* 避免收口后 label 冻结成"正在…(NN%)"进度中叙述。
* 与后端 taskrun.NormalizeSettledStep 语义对齐:failed 有意保留原文案(错误气泡承载细节),
* pending / running 不改动。
*/
export function settleStep(e: PhaseEvent): PhaseEvent {
if (e.status !== 'completed') return e;
const short = PHASE_META[phaseIdOfStep(e.step)]?.label ?? '任务推进';
// 保留"(第 x/y 步)"结构后缀(多步任务需区分进步骤),丢弃百分比与进度中叙述。
const idxSuffix = e.label.match(/(第 \d+\/\d+ 步)/)?.[0] ?? '';
const label = short + idxSuffix + '已完成';
if (e.label === label) return e; // 幂等
return { ...e, label };
}
function decorateLabel(base: string, phase: PhaseEventPayload): string {
const { step_index, total_steps, progress } = phase;
if (step_index && step_index > 0 && total_steps && total_steps > 0) {
return `${base}(第 ${step_index}/${total_steps} 步)`;
}
if (progress && progress > 0) {
return `${base}(${progress}%)`;
}
return base;
}
/** 条目 key:同 phase 且无有效 step_index 时刷新文案;带 step_index 的重复 phase 各占一节点 */
function stepKeyOf(phase: PhaseEventPayload): string {
const idx = phase.step_index ?? 0;
return idx > 0 ? `${phase.phase_id}#${idx}` : `${phase.phase_id}`;
}
/**
* applyPhaseEvent 把一条后端 phase 事件折叠进 PhaseEventStrip 序列(纯函数)。
* 进度求真:仅由真实 phase 事件驱动,无任何人为进度推演。
*/
export function applyPhaseEvent(strip: PhaseEvent[], phase?: PhaseEventPayload): PhaseEvent[] {
if (!phase?.phase_id) return strip;
const meta = PHASE_META[phase.phase_id] ?? { icon: '⏳', label: '任务推进' };
// 进度求真:优先展示后端下发的真实进度文案,短标签仅作缺省
const detail = phase.message?.trim() || undefined;
const base = detail || meta.label;
const label = decorateLabel(base, phase);
const key = stepKeyOf(phase);
// 已存在的条目:刷新文案;非失败事件再次到达时,failed 条目恢复 running(重试/续跑重新执行该步骤)
const existingIdx = strip.findIndex((e) => e.step === key);
if (existingIdx >= 0) {
return strip.map((e, i) => {
if (i !== existingIdx) return e;
if (e.status === 'failed' && phase.phase_id !== 'step_failed') {
return { ...e, label, detail, status: 'running' as const };
}
// 已收口条目被重复事件刷新文案时,保持 label 与 status 语义一致。
return settleStep({ ...e, label, detail });
});
}
// 步骤失败:把当前 running 条目置为 failed(红色视觉态);无 running 条目时追加失败节点
if (phase.phase_id === 'step_failed') {
if (strip.some((e) => e.status === 'running')) {
return strip.map((e) =>
e.status === 'running' ? { ...e, status: 'failed' as const, detail: detail ?? e.detail } : e,
);
}
return [...strip, { step: key, icon: meta.icon, label, detail, status: 'failed' as const }];
}
// 收口型 phase:running 条目置 completed,不追加新节点
if (CLOSE_PHASES.has(phase.phase_id)) {
return strip.map((e) => (e.status === 'running' ? settleStep({ ...e, status: 'completed' as const }) : e));
}
// 新 phase:先行条目全部置 completed,追加当前节点为 running
const settled = strip.map((e) =>
e.status === 'running' ? settleStep({ ...e, status: 'completed' as const }) : e
);
return [
...settled,
{ step: key, icon: meta.icon, label, detail, status: 'running' as const },
];
}