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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user