package runtime import ( "context" "strings" "testing" agentllm "git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/llm" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" ) // wp5_ab_test.go 是 N1 WP5 验收的 A/B 对照:门禁机制下沉后, // 同一组脚本化模型输出的停止原因、提醒次数与工具拒绝必须与记录的 // 旧行为逐条一致。既有套件(GuardTwiceThenNotice / VerifierRejectsThenPasses / // VerifierGarbageFailsClosed / TodoReminder 三测)在实现切换后原样通过, // 是性格化等价的主网;本文件补齐两个此前无覆盖的验收角度。 // TestVerifyCapNoticeAfterThreeContinuations:验证器持续拒绝时, // 续跑恰为 3 次(gates.MaxVerifyContinuations),随后以 verifyCapNotice 收尾, // 理由逐次进入过程区。 func TestVerifyCapNoticeAfterThreeContinuations(t *testing.T) { turns := [][]agentllm.Event{ textDeltaTurn("答案 v1。"), textDeltaTurn("答案 v2。"), textDeltaTurn("答案 v3。"), textDeltaTurn("答案 v4。"), } var mainCalls int base := scriptStream(turns, nil) r := &LLMRunner{ Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { mainCalls++ return base(ctx, model, msgs, tools, opts) }, VerifyStream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { return passedStream(`{"passed":false,"reason":"来源未标注","nextAction":"补上出处"}`)(ctx, model, msgs, tools, opts) }, } rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } if mainCalls != 4 { t.Fatalf("main calls = %d, want 4(3 次续跑 + 收尾)", mainCalls) } texts := textParts(rc.assistant) last := texts[len(texts)-1] // 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 for _, p := range rc.assistant { if p.Type == core.PartCheck && p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "来源未标注") { notes++ } } if notes != 4 { t.Fatalf("verify notes = %d, want 4(3 次续跑 + 1 次封顶); checks = %+v", notes, rc.assistant) } // 前三个被降级的回复进了过程区;最后一次回复与收尾 Notice 留在正式区。 demoted := 0 for _, p := range texts { if p.Process && strings.HasPrefix(p.Text, "答案 v") { demoted++ } } if demoted != 3 { t.Fatalf("demoted answers = %d, want 3; parts = %+v", demoted, texts) } } // TestGateReasonTextParityAfterSink:下沉后门禁分支理由逐字一致—— // verify 开启时,声称交付无工件的理由必须原样进入过程区。 func TestGateReasonTextParityAfterSink(t *testing.T) { turns := [][]agentllm.Event{ textDeltaTurn("文档已生成:甲.docx"), textDeltaTurn("文档已保存:乙.docx"), textDeltaTurn("文档已导出:丙.docx"), } r := &LLMRunner{Stream: scriptStream(turns, nil)} rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } if !hasProcessNote(rc.assistant, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件") { t.Fatalf("gate reason missing after sink: %+v", textParts(rc.assistant)) } texts := textParts(rc.assistant) last := texts[len(texts)-1] if last.Process || !strings.Contains(last.Text, "本轮未实际生成文件") { t.Fatalf("final notice = %+v", last) } // 伪造调用标记的分支理由也要逐字一致。 r2 := &LLMRunner{Stream: scriptStream([][]agentllm.Event{ textDeltaTurn("结果如下 [调用工具 write_file] 已完成"), textDeltaTurn("结果如下 [调用工具 write_file] 已完成"), textDeltaTurn("结果如下 [调用工具 write_file] 已完成"), }, nil)} rc2 := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true} if err := r2.Execute(rc2); err != nil { t.Fatalf("Execute: %v", err) } if !hasProcessNote(rc2.assistant, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用") { t.Fatalf("forged marker reason missing after sink: %+v", textParts(rc2.assistant)) } }