| 1 | package capability |
| 2 | |
| 3 | import "testing" |
| 4 | |
| 5 | func TestLedgerRequireAndPreferGates(t *testing.T) { |
| 6 | l := NewLedger() |
| 7 | l.SeedCandidates(RouteDecision{Candidates: []RouteCandidate{ |
| 8 | {Entry: Entry{ID: "skill:review"}, Policy: AutoUseRequire, Reason: "user asked"}, |
| 9 | {Entry: Entry{ID: "mcp-tool:github/search"}, Policy: AutoUsePrefer, Reason: "github"}, |
| 10 | }}) |
| 11 | |
| 12 | gate := l.CheckFinalGate() |
| 13 | if gate.Reason == "" || len(gate.RequireIDs) != 1 { |
| 14 | t.Fatalf("expected require missing, got %+v", gate) |
| 15 | } |
| 16 | |
| 17 | l.MarkSucceeded("skill:review") |
| 18 | gate = l.CheckFinalGate() |
| 19 | if !gate.PreferRemind || len(gate.PreferIDs) != 1 { |
| 20 | t.Fatalf("expected prefer reminder, got %+v", gate) |
| 21 | } |
| 22 | l.MarkReminded("mcp-tool:github/search") |
| 23 | gate = l.CheckFinalGate() |
| 24 | if gate.PreferRemind || len(gate.PreferIDs) != 1 { |
| 25 | t.Fatalf("expected prefer hard fail after reminder, got %+v", gate) |
| 26 | } |
| 27 | if err := l.MarkDeclined("mcp-tool:github/search", "not needed for this edit"); err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | gate = l.CheckFinalGate() |
| 31 | if gate.Reason != "" { |
| 32 | t.Fatalf("expected clear after decline, got %+v", gate) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | func TestLedgerDeclineCannotSkipRequire(t *testing.T) { |
| 37 | l := NewLedger() |
| 38 | l.SeedCandidates(RouteDecision{Candidates: []RouteCandidate{ |
| 39 | {Entry: Entry{ID: "skill:audit"}, Policy: AutoUseRequire}, |
| 40 | }}) |
| 41 | // MarkDeclined is allowed on ledger; host use_capability rejects require declines. |
| 42 | if err := l.MarkDeclined("skill:audit", "skip"); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | // After decline of require, CheckFinalGate still wants success unless unavailable. |
| 46 | // Decline sets outcome declined; require path only accepts succeeded/unavailable. |
| 47 | gate := l.CheckFinalGate() |
| 48 | if gate.Reason == "" { |
| 49 | t.Fatal("declined require should still block final delivery") |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestSemanticPoolRequiresLexicalOverlap(t *testing.T) { |
| 54 | entries := []Entry{ |
| 55 | {ID: "skill:review", Kind: KindSkill, Name: "review", Description: "code review", AutoUse: AutoUsePrefer}, |
| 56 | {ID: "skill:quiet", Kind: KindSkill, Name: "quiet", Description: "unrelated", AutoUse: AutoUseSuggest}, |
| 57 | } |
| 58 | pool := semanticPool("please review this change", entries) |
| 59 | if len(pool) != 1 || pool[0].ID != "skill:review" { |
| 60 | t.Fatalf("pool = %+v, want only review", pool) |
| 61 | } |
| 62 | if pool2 := semanticPool("capture request prefix", entries); len(pool2) != 0 { |
| 63 | t.Fatalf("unrelated text should not open semantic pool: %+v", pool2) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestSemanticPoolAllowsBoundedChineseBuiltinFallback(t *testing.T) { |
| 68 | entries := []Entry{ |
| 69 | {ID: "skill:explore", Kind: KindSkill, Name: "explore", Source: "builtin", Description: "inspect architecture", AutoUse: AutoUseSuggest}, |
| 70 | {ID: "skill:custom", Kind: KindSkill, Name: "custom", Source: "project", Description: "unrelated custom workflow", AutoUse: AutoUseSuggest}, |
| 71 | } |
| 72 | pool := semanticPool("帮我梳理一下这个功能", entries) |
| 73 | if len(pool) != 1 || pool[0].ID != "skill:explore" { |
| 74 | t.Fatalf("Chinese fallback pool = %+v, want only the bounded built-in candidate", pool) |
| 75 | } |
| 76 | } |
| 77 |