fix(taskrun): 收口 completed 步骤时归一化冻结的进度中 label

步骤从 running 收口为 completed 时,此前只翻转 Status,Label 永久冻结为
上游下发的"正在…(NN%)"进度中叙述,导致卡片即便步骤已完成仍读起来像还在跑
(缺陷见消息 #4424)。

新增导出函数 NormalizeSettledStep:completed 步骤改写为短标签 + "已完成",
保留"(第 x/y 步)"结构后缀以区分多步任务,原始叙述留在 Detail 供审计;
failed/pending/running 有意不改(失败细节由错误气泡承载)。
接入 applyPhasePayload 三处收口分支、existing-step 刷新分支,
以及 BuildPhaseSnapshot 终态收口 + 存量兜底全量归一化。

导出供回填脚本复用,避免逻辑漂移。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 10:32:38 +00:00
co-authored by Cursor
parent 10aee3feb4
commit 561709178a
2 changed files with 117 additions and 1 deletions
+52 -1
View File
@@ -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(去掉 "#<idx>" 后缀)。
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)
+65
View File
@@ -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)
}
}