diff --git a/internal/runtime/final_reply_test.go b/internal/runtime/final_reply_test.go new file mode 100644 index 0000000..6bf8e53 --- /dev/null +++ b/internal/runtime/final_reply_test.go @@ -0,0 +1,97 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + core "agent-system-core" +) + +// 问题一回归(run_b520bf2e3037):事实核查修正轮触发时,现场那段正式总结必须 +// 降级进过程时间线;修正轮的收尾才是最终答复——否则前端按「最后一个过程部件」 +// 切分后,用户只看到修正流水账,看不到原总结。 +func TestFactCheckDemotesPriorSummary(t *testing.T) { + rc := &fakeRunCtx{ + ctx: context.Background(), + prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model", Lightweight: "lite-model"}}, + verify: true, + execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"}, + execArtifactContent: []byte("# 报告\n\n准确率 95%。"), + } + turns := padTurns( + textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)), + textDeltaTurn("已完成。## 产出物\n\n结构完整的用户总结。"), + textDeltaTurn("已修正并重新登记。"), + ) + r := &LLMRunner{Stream: scriptStream(turns, nil), VerifyStream: passedStream()} + if err := r.Execute(rc); err != nil { + t.Fatalf("Execute: %v", err) + } + var summary *core.Part + visible := []string{} + sawFactCheck := false + for i := range rc.assistant { + p := rc.assistant[i] + if p.Type == core.PartText && strings.Contains(p.Text, "结构完整的用户总结") { + summary = &rc.assistant[i] + } + if p.Type == core.PartText && !p.Process { + visible = append(visible, p.Text) + } + if p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "事实核查") { + sawFactCheck = true + } + } + if summary == nil { + t.Fatal("prior summary text missing") + } + if !summary.Process { + t.Fatalf("prior summary not demoted to process: %+v", *summary) + } + if !sawFactCheck { + t.Fatal("fact check note missing") + } + if len(visible) != 1 || visible[0] != "已修正并重新登记。" { + t.Fatalf("visible final texts = %v, want only the continuation closing", visible) + } +} + +// 同源跳过提示不再作为用户可见正文追加(原先经 StopDecision.Notice 顶掉正式总结), +// 改记为通过态检查行留在过程时间线。 +func TestVerifierSkipIsCheckNotFinalText(t *testing.T) { + rc := &fakeRunCtx{ + ctx: context.Background(), + prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, // 轻量回落=主模型 → 同源跳过 + verify: true, + } + turns := padTurns(textDeltaTurn("总结完成。")) + r := &LLMRunner{Stream: scriptStream(turns, nil)} // 无 VerifyStream + if err := r.Execute(rc); err != nil { + t.Fatalf("Execute: %v", err) + } + skipCheck, noticeText := false, false + visible := []string{} + for _, p := range rc.assistant { + if p.Check != nil && p.Check.Passed && strings.Contains(p.Check.Reason, "同源模型验证") { + skipCheck = true + } + if p.Type == core.PartText { + if strings.Contains(p.Text, verifierUnavailableNotice) { + noticeText = true + } + if !p.Process { + visible = append(visible, p.Text) + } + } + } + if !skipCheck { + t.Fatal("same-source skip not recorded as a passing check") + } + if noticeText { + t.Fatal("skip notice leaked into message text parts") + } + if len(visible) != 1 || visible[0] != "总结完成。" { + t.Fatalf("visible final texts = %v", visible) + } +} diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 1ba3f70..5dabe90 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -537,15 +537,26 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim if len(pairs) > 0 { if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 { st.factChecked = true + // 与交付物门禁/续写上限分支对齐:检查未通过时,现场那段总结归入 + // 过程时间线——修正轮之后的收尾才是最终答复(实测 run_b520bf2e3037 + // 未降级导致好总结被前端切分折叠,用户只看到修正流水账)。 + st.demoteLastText() st.addVerifyNote("事实核查:" + factCheckNotice(tokens)) - return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物。\n" + factCheckNotice(tokens))} + return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物,然后重新给出更新后的完整最终答复(先结论、再产出物与要点,可注明本次修正)。\n" + factCheckNotice(tokens))} } } } verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest) if err != nil { - st.addVerifyNote(err.Error()) - return nruntime.StopDecision{Notice: verifierUnavailableNotice} + // 验证器没跑成不再是用户可见正文(实测 Notice 会顶掉正式总结)。 + // 同源跳过是能力说明,记通过态检查行;真失败记未通过态检查行, + // 过程时间线里都可见,最终答复只留模型自己的收尾。 + if strings.Contains(err.Error(), "跳过同源验证") { + st.addCheck(true, verifierUnavailableNotice) + } else { + st.addVerifyNote(err.Error()) + } + return nruntime.StopDecision{} } if !verdict.Passed { reason := strings.TrimSpace(verdict.Reason) @@ -640,7 +651,10 @@ func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nru return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)} } st.addVerifyNote(reason) - return nruntime.StopDecision{Notice: verifyCapNotice} + // 达到续写上限同样收进检查行(未通过态):让用户看见检查确实没过, + // 而不是用一句系统提示替换掉模型的最终答复。 + st.addCheck(false, verifyCapNotice) + return nruntime.StopDecision{} } func (st *directRun) runTool(name, args string) agenttools.Result { diff --git a/internal/runtime/llm_verify_test.go b/internal/runtime/llm_verify_test.go index c6f474d..95e449a 100644 --- a/internal/runtime/llm_verify_test.go +++ b/internal/runtime/llm_verify_test.go @@ -214,16 +214,18 @@ func TestVerifierGarbageFailsClosed(t *testing.T) { t.Fatalf("main calls = %d, want 1", mainCalls) } texts := textParts(rc.assistant) - var kept, notice bool + var kept bool + notice := false for _, p := range texts { if p.Text == "答案是 42。" && !p.Process { kept = true } - if !p.Process && strings.Contains(p.Text, "验证器未独立配置") { + if !p.Process && strings.Contains(p.Text, "验证器") { notice = true } } - if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") && !hasProcessNote(rc.assistant, "未独立配置") { + // 2026-10-10 契约更新:验证器不可用不再作为正文 Notice,落成过程检查行。 + if !kept || notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") { t.Fatalf("parts = %+v", texts) } }) diff --git a/internal/runtime/wp5_ab_test.go b/internal/runtime/wp5_ab_test.go index ab4c9d5..084d500 100644 --- a/internal/runtime/wp5_ab_test.go +++ b/internal/runtime/wp5_ab_test.go @@ -45,8 +45,13 @@ func TestVerifyCapNoticeAfterThreeContinuations(t *testing.T) { } texts := textParts(rc.assistant) last := texts[len(texts)-1] - if last.Process || !strings.Contains(last.Text, "本轮回复未通过终轮检查") { - t.Fatalf("final notice = %+v, want non-process verify cap notice", last) + // 2026-10-10 契约更新:封顶提示不再作为正文 Notice;最后一次回复留在正式区, + // 封顶事实以未通过态检查行收进过程区。 + if last.Process || last.Text != "答案 v4。" { + t.Fatalf("final text = %+v, want the model's own last reply", last) + } + if !hasProcessNote(rc.assistant, "本轮回复未通过终轮检查") { + t.Fatalf("cap check note missing: %+v", rc.assistant) } // 理由经 CheckPart 进过程区:3 次续跑各记一次,达上限时再记一次(与旧行为一致)。 notes := 0