ci / vet-and-test (pull_request) Successful in 40s
Why: The consecutive-failure counter never fired in the live suite (0 reminders despite 20 failures) because real failures interleave with successes - each success reset the streak. The pattern to catch is "spinning overall", not "N in a row". Evidence: go test ./... green; the anchor test now covers scattered failures (3 in the last 6 outcomes escalates, 2 does not, once per run); the next live run must show the reminder actually appearing in the transcript. Impact: recentOutcomes ring (last 6) replaces consecToolFailures; the zero-hit search trigger keeps its own threshold (2); message wording matches the window semantics. Refs: Dev task feature/agent-e4-escalation-ledger (second fix-up).
113 lines
4.5 KiB
Go
113 lines
4.5 KiB
Go
package runtime
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// ---- E4:R4 升级提醒 ----
|
||
|
||
// 滑动窗口判据:近 6 次结果中 3 次失败(可与成功交错)触发换路指令;
|
||
// 每 run 一次;2 次失败不触发。
|
||
func TestEscalationOnConsecutiveToolFailures(t *testing.T) {
|
||
st := &directRun{l: &LLMRunner{}, inflight: map[string]*toolSlot{}}
|
||
st.noteFailureOutcome("ok")
|
||
st.noteFailureOutcome("error")
|
||
st.noteFailureOutcome("ok")
|
||
st.noteFailureOutcome("error")
|
||
if got := st.escalationReminders(); len(got) != 0 {
|
||
t.Fatalf("two scattered failures must not escalate: %v", got)
|
||
}
|
||
st.noteFailureOutcome("ok")
|
||
st.noteFailureOutcome("error") // 窗口内第 3 败(交错分布)
|
||
got := st.escalationReminders()
|
||
if len(got) != 1 || !strings.Contains(got[0], "停止原样重试") {
|
||
t.Fatalf("three failures in the window must escalate: %v", got)
|
||
}
|
||
if again := st.escalationReminders(); len(again) != 0 {
|
||
t.Fatalf("escalation must fire once per run: %v", again)
|
||
}
|
||
}
|
||
|
||
// 检索连续空结果(成功但无实质内容)触发「换关键词或如实写缺失」。
|
||
func TestEscalationOnZeroHitSearches(t *testing.T) {
|
||
f := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execOutput: "未找到相关内容"}
|
||
r := &LLMRunner{Stream: scriptStream(padTurns(
|
||
textDeltaTurn("", toolCall("knowledge_search", `{"query":"a"}`), toolCall("knowledge_search", `{"query":"b"}`)),
|
||
textDeltaTurn("好了。"),
|
||
), nil)}
|
||
if err := r.Execute(f); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
st := &directRun{l: &LLMRunner{}, inflight: map[string]*toolSlot{}}
|
||
st.noteEvidenceCall("knowledge_search", "ok", "未找到相关内容")
|
||
if got := st.escalationReminders(); len(got) != 0 {
|
||
t.Fatalf("one zero hit must not escalate: %v", got)
|
||
}
|
||
st.noteEvidenceCall("knowledge_search", "ok", "无结果")
|
||
got := st.escalationReminders()
|
||
if len(got) != 1 || !strings.Contains(got[0], "材料中无此信息") {
|
||
t.Fatalf("two zero hits must escalate: %v", got)
|
||
}
|
||
// 有实质结果后计数重置。
|
||
st.noteEvidenceCall("knowledge_search", "ok", "营收 3,200 万元")
|
||
if st.zeroHitSearches != 0 {
|
||
t.Fatal("substantive hit must reset zero-hit count")
|
||
}
|
||
}
|
||
|
||
// ---- E4:结构化 source_id 账本 ----
|
||
|
||
func TestEvidenceLedgerTagsSource(t *testing.T) {
|
||
f := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execOutput: "上半年营收 3,200 万元。"}
|
||
r := &LLMRunner{Stream: scriptStream(padTurns(
|
||
textDeltaTurn("", toolCall("knowledge_search", `{"query":"营收"}`)),
|
||
textDeltaTurn("总结完成。"),
|
||
), nil)}
|
||
if err := r.Execute(f); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 结果文本必须带来源标记,且账本登记了对应条目(直接经 runTool 断言)。
|
||
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
|
||
res := st.runTool("knowledge_search", `{}`)
|
||
if !strings.Contains(res.Content, "【src_1|knowledge_search】") {
|
||
t.Fatalf("tool result must carry the source tag: %.120s", res.Content)
|
||
}
|
||
if len(st.ledger) != 1 || st.ledger[0].ID != "src_1" || !strings.Contains(st.ledger[0].Text, "3,200") {
|
||
t.Fatalf("ledger = %+v", st.ledger)
|
||
}
|
||
// 第二次登记递增编号。
|
||
res2 := st.runTool("fs_read_file", `{}`)
|
||
if !strings.Contains(res2.Content, "【src_2|fs_read_file】") {
|
||
t.Fatalf("second tag must be src_2: %.120s", res2.Content)
|
||
}
|
||
}
|
||
|
||
// 非证据工具不登记、不加标记。
|
||
func TestEvidenceLedgerSkipsNonEvidenceTools(t *testing.T) {
|
||
f := &fakeRunCtx{}
|
||
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
|
||
res := st.runTool("time_now", `{}`)
|
||
if strings.Contains(res.Content, "src_") || len(st.ledger) != 0 {
|
||
t.Fatalf("non-evidence tool must not enter the ledger: %.80s %+v", res.Content, st.ledger)
|
||
}
|
||
}
|
||
|
||
// 材料读(inputs/)打标;模型自产文件的读与 grep 不打——尾注会污染
|
||
// read→edit→build 编辑循环(E4 首轮 eval 实测教训,回归锚点)。
|
||
func TestEvidenceLedgerOnlyTagsMaterialReads(t *testing.T) {
|
||
if !isEvidenceRead("read_file", json.RawMessage(`{"path":"inputs/年报.docx.md"}`)) {
|
||
t.Fatal("inputs/ read must count as evidence")
|
||
}
|
||
if isEvidenceRead("read_file", json.RawMessage(`{"path":"方案.md"}`)) {
|
||
t.Fatal("own-workspace read must NOT count as evidence")
|
||
}
|
||
if isEvidenceRead("grep", json.RawMessage(`{"pattern":"营收"}`)) {
|
||
t.Fatal("grep must never count as evidence")
|
||
}
|
||
}
|