Files
weichen ef060d4ee5
ci / vet-and-test (pull_request) Successful in 40s
Keep only hard facts in the blocking gate and add fact-consistency checks
Why: User rulings 4/5 plus the P2-P4 live evidence. Keyword scoring dimensions
(readability, actionability) kept failing valid documents - normal Chinese
section headings flagged as column titles, loops of 4 corrections - because
they grade writing preferences, not verifiable facts. Unverified numbers
(~10-13 per run) remained the largest quality leak through every phase.
And the verifier runs on the same model as the worker (lightweight falls back
to chat), so "independent" verification was self-review. Separately, svc
seeding after boot never registered the tavily tools until a restart because
keys were snapshotted at startup.

Changes: quality.HardFacts (lint actually ran + zero errors) replaces the
full ScoreBytes projection in CheckArtifacts - keyword dimensions remain in
ScoreBytes for eval visibility only. New fact-consistency check
(runtime.factCheck, default on): extract percentages/dates/large numbers from
deliverable text, trace each against the material corpus (attachment text +
tool evidence); misses get exactly one correction round (fix, drop, or mark
as target/estimate), never a loop. runVerifier skips same-source model
verification unless runtime.verifierSameModel is set (hard facts + fact check
still run). Search tools now register lazily, reading tavily keys per call
(settings/seeding take effect immediately).

Evidence: SELF - TestHardFactsIgnoresKeywordDimensions,
TestHardFactsKeepsLintUnavailableBlocking, TestUnverifiedFactsAndNotice,
TestFactCheckContinuationOnce (fact note fires, corrected run passes);
go vet + go test ./... pass; live before/after on the golden task set
recorded on the phase05 task card per the policy clause.

Impact: Gate stops rejecting valid documents on stylistic grounds; invented
figures get one forced correction round; same-model verification no longer
pretends to be independent; tavily config is hot. No contract or storage
changes.
2026-10-10 00:04:57 +00:00

129 lines
4.8 KiB
Go

package plugins
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
core "agent-system-core"
)
func TestV2AssetsAndNetwork(t *testing.T) {
host, _, dir := newHost(t)
skills := filepath.Join(t.TempDir(), "skills")
host.SetSkillsRoot(skills)
root := filepath.Join(dir, "v2")
for _, sub := range []string{"skills", "mcp", "knowledge", "parsers", "agents", "channels", "pages", "tools"} {
if err := os.MkdirAll(filepath.Join(root, sub), 0o755); err != nil {
t.Fatal(err)
}
}
write := func(rel, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, rel), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("plugin.json", `{
"id":"v2","name":"V2","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`",
"requires":{"network":["api.example.com"]},
"scope":{"tenants":["acme"]},
"backend":{"schemaVersion":1},
"provides":{"tools":["echo"],"skills":["note"],"mcpServers":["local"],"knowledge":["regs"],"parsers":["pdf"],"agents":["writer"],"channels":["feishu"],"pages":["home"]}
}`)
write("tools/echo.json", `{"name":"v2_echo","description":"e","parameters":{"type":"object"},"http":{"method":"GET","url":"https://api.example.com/x"}}`)
write("skills/note.md", "---\nname: note\ndescription: 笔记技能\n---\n\n# note\n")
write("mcp/local.json", `{"name":"local","command":"echo"}`)
write("knowledge/regs.json", `{"id":"regs","title":"法规"}`)
write("parsers/pdf.json", `{"id":"pdf","title":"PDF","mime":"application/pdf"}`)
write("agents/writer.json", `{"id":"writer","title":"写作"}`)
write("channels/feishu.json", `{"id":"feishu","kind":"feishu","title":"飞书"}`)
write("pages/home.json", `{"id":"home","title":"首页","module":"./page.mjs"}`)
if err := host.LoadDir()["v2"]; err != nil {
t.Fatal(err)
}
ld := host.List()[0]
if ld.Skills["note"] == "" || len(ld.MCP) != 1 || len(ld.Pages) != 1 || len(ld.Channels) != 1 {
t.Fatalf("assets: %+v", ld)
}
if _, err := os.Stat(filepath.Join(skills, "plugin-v2", "note", "SKILL.md")); err != nil {
t.Fatal(err)
}
if got := host.ListFor("other", ""); len(got) != 0 {
t.Fatalf("scope leak: %d", len(got))
}
if got := host.ListFor("acme", ""); len(got) != 1 {
t.Fatalf("scope miss: %d", len(got))
}
if err := host.st.SetPluginEnabled("acme", "v2", false); err != nil {
t.Fatal(err)
}
if got := host.ListFor("acme", ""); len(got) != 0 {
t.Fatal("grant should hide plugin")
}
}
func TestExperimentalNetworkDeny(t *testing.T) {
host, _, dir := newHost(t)
root := filepath.Join(dir, "open")
os.MkdirAll(filepath.Join(root, "tools"), 0o755)
os.WriteFile(filepath.Join(root, "plugin.json"), []byte(`{"id":"open","name":"O","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`","provides":{"tools":["e"]}}`), 0o644)
os.WriteFile(filepath.Join(root, "tools", "e.json"), []byte(`{"name":"open_e","description":"e","parameters":{"type":"object"},"http":{"method":"GET","url":"https://example.com"}}`), 0o644)
if err := host.LoadDir()["open"]; err == nil {
t.Fatal("v2 without network allowlist must fail closed")
}
}
func TestSidecarTouch(t *testing.T) {
host, _, dir := newHost(t)
host.SetHostAPI("http://127.0.0.1:1", "tok")
root := filepath.Join(dir, "side")
os.MkdirAll(root, 0o755)
mark := filepath.Join(t.TempDir(), "up")
os.WriteFile(filepath.Join(root, "plugin.json"), []byte(`{"id":"side","name":"S","version":"0.1.0","apiVersion":"1.0","backend":{"mode":"sidecar","command":"/bin/touch","args":["`+mark+`"],"schemaVersion":1},"provides":{}}`), 0o644)
if err := host.LoadDir()["side"]; err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(mark); err == nil {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("sidecar did not run")
}
// N2 WP4:不合规插件技能整批拒载,错误带插件上下文与字段定位。
func TestV2InvalidSkillRejected(t *testing.T) {
host, _, dir := newHost(t)
root := filepath.Join(dir, "badskill")
if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
t.Fatal(err)
}
write := func(rel, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, rel), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("plugin.json", `{
"id":"badskill","name":"Bad","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`",
"backend":{"schemaVersion":1},
"provides":{"skills":["note"]}
}`)
// 缺 name(开放规范必填)。
write("skills/note.md", "---\ndescription: 没有名字\n---\n正文\n")
err := host.LoadDir()["badskill"]
if err == nil {
t.Fatal("不合规技能必须拒载")
}
if !strings.Contains(err.Error(), "plugins: badskill: skill note:") ||
!strings.Contains(err.Error(), "name") {
t.Fatalf("错误须带插件上下文与字段定位:%v", err)
}
}