ci / vet-and-test (pull_request) Successful in 1m1s
Why: real run run_b520bf2e3037 showed the user-facing reply being displaced by internal bookkeeping - the fact-check correction round's closing text plus the same-source verifier skip notice became the visible answer while the actual summary was folded into the process timeline (frontend splits at the last process part). Same tail pollution reproduced on every same-model run since. Evidence: go test ./... -count=1 green (32 pkgs); new regression tests cover the demotion of the pre-check summary and the skip notice becoming a check part; three old-contract tests updated to assert notices land as process checks instead of final text. Impact: stop() fact-check branch now demotes the standing summary (aligned with the gate and cap branches) and asks the model for a fresh full reply; verifier errors record as process checks (pass-state for same-source skip, fail-state for real errors) instead of StopDecision.Notice text; the cap notice likewise becomes a failed check line. Refs: Dev task feature/final-reply-pollution-fix; diagnosis in .task/feature-dual-speed-tasks.md lineage and session analysis 2026-10-10.
113 lines
4.7 KiB
Go
113 lines
4.7 KiB
Go
package runtime
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
|
||
agentllm "agent-native/runtime/llm"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// 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))
|
||
}
|
||
}
|