diff --git a/internal/taskrun/phase_snapshot.go b/internal/taskrun/phase_snapshot.go index ba18d679..15e46fab 100644 --- a/internal/taskrun/phase_snapshot.go +++ b/internal/taskrun/phase_snapshot.go @@ -2,11 +2,15 @@ package taskrun import ( "fmt" + "regexp" "strings" "chat-agent-system/internal/stream" ) +// stepIndexSuffixRe 匹配 decoratePhaseLabel 产生的"(第 x/y 步)"结构后缀。 +var stepIndexSuffixRe = regexp.MustCompile(`(第 \d+/\d+ 步)`) + // PhaseStepSnapshot 折叠后的单步进度(与前端 PhaseEvent 字段对齐)。 type PhaseStepSnapshot struct { Step string `json:"step"` @@ -68,9 +72,15 @@ func BuildPhaseSnapshot(events []StoredEvent, durationMs int64, status, title st } else { steps[i].Status = "completed" } + steps[i] = NormalizeSettledStep(steps[i]) } } } + // 写出快照前再统一归一化 completed 步骤 label:applyPhasePayload 各收口分支已归一化, + // 此处作为防御性兜底,确保任何路径落库的 completed 步骤文案都与终态一致(幂等)。 + for i := range steps { + steps[i] = NormalizeSettledStep(steps[i]) + } return &PhaseSnapshot{ Steps: steps, Label: label, @@ -125,7 +135,8 @@ func applyPhasePayload(strip []PhaseStepSnapshot, phase *stream.PhasePayload) [] if cur.Status == "failed" && phase.PhaseID != "step_failed" { cur.Status = "running" } - strip[idx] = cur + // 已收口步骤被重复事件刷新文案时,保持 label 与 status 语义一致。 + strip[idx] = NormalizeSettledStep(cur) return strip } @@ -139,6 +150,7 @@ func applyPhasePayload(strip []PhaseStepSnapshot, phase *stream.PhasePayload) [] if detail != "" { out[i].Detail = detail } + // 失败节点有意保留原步骤文案(红色图标 + 错误气泡承载细节),不归一化 label。 } } return out @@ -154,6 +166,7 @@ func applyPhasePayload(strip []PhaseStepSnapshot, phase *stream.PhasePayload) [] for i := range out { if out[i].Status == "running" { out[i].Status = "completed" + out[i] = NormalizeSettledStep(out[i]) } } return out @@ -164,6 +177,7 @@ func applyPhasePayload(strip []PhaseStepSnapshot, phase *stream.PhasePayload) [] for i := range settled { if settled[i].Status == "running" { settled[i].Status = "completed" + settled[i] = NormalizeSettledStep(settled[i]) } } return append(settled, PhaseStepSnapshot{ @@ -171,6 +185,43 @@ func applyPhasePayload(strip []PhaseStepSnapshot, phase *stream.PhasePayload) [] }) } +// phaseIDOfStep 从 step key 还原出 phase_id(去掉 "#" 后缀)。 +func phaseIDOfStep(step string) string { + if i := strings.IndexByte(step, '#'); i >= 0 { + return step[:i] + } + return step +} + +// NormalizeSettledStep 把处于 completed 终态的步骤标签归一化为静态措辞。 +// +// 收口时若只翻转 Status 而不动 Label,label 会永久冻结成"正在…(NN%)""(第 x/y 步)" +// 这类进度中叙述,导致卡片即便步骤已完成仍读起来像"还在跑"(缺陷见消息 #4424)。 +// 这里统一改写成 phaseMetaTable 短标签 + "已完成",Detail 保留原始叙述供审计。 +// +// 仅处理 completed:failed 步骤有意保留原步骤文案(红色图标 + 错误气泡承载失败细节); +// pending / running 不改动。导出供回填脚本复用,避免逻辑漂移。 +func NormalizeSettledStep(step PhaseStepSnapshot) PhaseStepSnapshot { + if step.Status != "completed" { + return step + } + short := phaseMetaTable[phaseIDOfStep(step.Step)].Label + if short == "" { + short = "任务推进" + } + const suffix = "已完成" + // 保留"(第 x/y 步)"结构后缀(多步任务需区分进步骤),丢弃百分比与进度中叙述。 + label := short + suffix + if loc := stepIndexSuffixRe.FindStringIndex(step.Label); loc != nil { + label = short + step.Label[loc[0]:loc[1]] + suffix + } + if step.Label == label { + return step // 幂等 + } + step.Label = label + return step +} + func phaseStepKey(phase *stream.PhasePayload) string { if phase.StepIndex > 0 { return fmt.Sprintf("%s#%d", phase.PhaseID, phase.StepIndex) diff --git a/internal/taskrun/phase_snapshot_test.go b/internal/taskrun/phase_snapshot_test.go index ce10986e..5072eee6 100644 --- a/internal/taskrun/phase_snapshot_test.go +++ b/internal/taskrun/phase_snapshot_test.go @@ -27,6 +27,13 @@ func TestFoldPhaseEvents_MirrorsFrontendSemantics(t *testing.T) { if steps[1].Detail != "撰写中" { t.Fatalf("detail=%q", steps[1].Detail) } + // 收口后的 label 必须归一化为静态措辞:进度中叙述被短标签替换,步数结构后缀保留。 + if steps[0].Label != "组建小队已完成" { + t.Fatalf("steps[0].label=%q want 组建小队已完成", steps[0].Label) + } + if steps[1].Label != "专员执行(第 1/2 步)已完成" { + t.Fatalf("steps[1].label=%q want 专员执行(第 1/2 步)已完成", steps[1].Label) + } } func TestBuildPhaseSnapshot_SettlesRunningOnTerminal(t *testing.T) { @@ -43,6 +50,10 @@ func TestBuildPhaseSnapshot_SettlesRunningOnTerminal(t *testing.T) { if len(snap.Steps) != 1 || snap.Steps[0].Status != "completed" { t.Fatalf("steps %+v", snap.Steps) } + // 终态收口后 label 不得冻结为"门禁中(90%)"。 + if snap.Steps[0].Label != "质量门禁已完成" { + t.Fatalf("label=%q want 质量门禁已完成", snap.Steps[0].Label) + } } func TestBuildPhaseSnapshot_FailedMarksRunningFailed(t *testing.T) { @@ -53,4 +64,58 @@ func TestBuildPhaseSnapshot_FailedMarksRunningFailed(t *testing.T) { if snap.Steps[0].Status != "failed" { t.Fatalf("status=%s", snap.Steps[0].Status) } + // 失败步骤有意保留原步骤文案(错误细节由错误气泡承载),不做归一化。 + if snap.Steps[0].Label != "执行中" { + t.Fatalf("label=%q want 执行中", snap.Steps[0].Label) + } +} + +// TestBuildPhaseSnapshot_AdvisorBriefingFrozenLabel 复现消息 #4424: +// advisor_briefing 以 running + "正在…(98%)" 落盘后被终态收口, +// label 必须归一化,任务不得展示"已完成却像还在跑"的冻结文案。 +func TestBuildPhaseSnapshot_AdvisorBriefingFrozenLabel(t *testing.T) { + events := []taskrun.StoredEvent{ + {Seq: 1, Event: stream.StreamEvent{Type: stream.EventPhase, Phase: &stream.PhasePayload{PhaseID: "squad_assembly", Message: "正在组建并启动【用户初始需求收集表】智能体小队(后端: pi-agent)...", Progress: 5}}}, + {Seq: 2, Event: stream.StreamEvent{Type: stream.EventPhase, Phase: &stream.PhasePayload{PhaseID: "step_executing", Message: "审核主管正在进行:审核并定稿交付..."}}}, + {Seq: 3, Event: stream.StreamEvent{Type: stream.EventPhase, Phase: &stream.PhasePayload{PhaseID: "quality_gate", Message: "质量门禁审核通过(评分: 100.0 / 100)", Progress: 95}}}, + {Seq: 4, Event: stream.StreamEvent{Type: stream.EventPhase, Phase: &stream.PhasePayload{PhaseID: "advisor_briefing", Message: "贴身业务参谋正在提炼关键决策摘要与风险雷达...", Progress: 98}}}, + } + snap := taskrun.BuildPhaseSnapshot(events, 244_864, taskrun.StatusPendingAuthorConfirmation, "用户初始需求收集表") + if snap == nil { + t.Fatal("nil snapshot") + } + if len(snap.Steps) != 4 { + t.Fatalf("steps=%d want 4", len(snap.Steps)) + } + want := []string{"组建小队已完成", "专员执行已完成", "质量门禁已完成", "参谋简报已完成"} + for i, st := range snap.Steps { + if st.Status != "completed" { + t.Fatalf("steps[%d].status=%s want completed", i, st.Status) + } + if st.Label != want[i] { + t.Fatalf("steps[%d].label=%q want %q", i, st.Label, want[i]) + } + } + // Detail 保留原始叙述(当前 UI 不展示,供审计)。 + if snap.Steps[3].Detail != "贴身业务参谋正在提炼关键决策摘要与风险雷达..." { + t.Fatalf("steps[3].detail=%q", snap.Steps[3].Detail) + } +} + +// TestNormalizeSettledStep_Idempotent 保证回填脚本可安全重复执行。 +func TestNormalizeSettledStep_Idempotent(t *testing.T) { + step := taskrun.PhaseStepSnapshot{Step: "advisor_briefing#2", Status: "completed", Label: "贴身业务参谋正在提炼关键决策摘要与风险雷达...(98%)"} + once := taskrun.NormalizeSettledStep(step) + if once.Label != "参谋简报已完成" { + t.Fatalf("label=%q", once.Label) + } + twice := taskrun.NormalizeSettledStep(once) + if twice != once { + t.Fatalf("not idempotent: %+v vs %+v", once, twice) + } + // running / pending 不改动。 + run := taskrun.PhaseStepSnapshot{Step: "quality_gate", Status: "running", Label: "门禁中(90%)"} + if got := taskrun.NormalizeSettledStep(run); got != run { + t.Fatalf("running step mutated: %+v", got) + } }