| 1 | package capability |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/skill" |
| 9 | "reasonix/internal/tool" |
| 10 | ) |
| 11 | |
| 12 | func TestRoutePrefersReviewSkillForReviewRequest(t *testing.T) { |
| 13 | entries := SkillEntries([]skill.Skill{{ |
| 14 | Name: "review", |
| 15 | Description: "review code for bugs", |
| 16 | Scope: skill.ScopeBuiltin, |
| 17 | }}, []tool.ContractEntry{{Name: "run_skill"}}) |
| 18 | |
| 19 | decision := Route("帮我看看这段代码有没有问题", entries) |
| 20 | if len(decision.Candidates) == 0 { |
| 21 | t.Fatal("Route returned no candidates") |
| 22 | } |
| 23 | got := decision.Candidates[0] |
| 24 | if got.Entry.ID != "skill:review" || got.Policy != AutoUsePrefer { |
| 25 | t.Fatalf("candidate = %+v, want review/prefer", got) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func TestRouteRequiresExplicitSkill(t *testing.T) { |
| 30 | entries := SkillEntries([]skill.Skill{{ |
| 31 | Name: "audit", |
| 32 | Description: "audit something", |
| 33 | Scope: skill.ScopeProject, |
| 34 | }}, []tool.ContractEntry{{Name: "run_skill"}}) |
| 35 | |
| 36 | decision := Route("请使用 audit skill 检查一下", entries) |
| 37 | if len(decision.Candidates) == 0 { |
| 38 | t.Fatal("Route returned no candidates") |
| 39 | } |
| 40 | if got := decision.Candidates[0].Policy; got != AutoUseRequire { |
| 41 | t.Fatalf("policy = %s, want require", got) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestRouteRespectsSkillAutoUseMetadata(t *testing.T) { |
| 46 | entries := SkillEntries([]skill.Skill{ |
| 47 | { |
| 48 | Name: "quiet", |
| 49 | Description: "quiet skill", |
| 50 | Scope: skill.ScopeProject, |
| 51 | Triggers: []string{"inspect"}, |
| 52 | AutoUse: "off", |
| 53 | }, |
| 54 | { |
| 55 | Name: "gentle", |
| 56 | Description: "gentle skill", |
| 57 | Scope: skill.ScopeProject, |
| 58 | Triggers: []string{"inspect"}, |
| 59 | AutoUse: "suggest", |
| 60 | }, |
| 61 | }, []tool.ContractEntry{{Name: "run_skill"}}) |
| 62 | |
| 63 | decision := Route("please inspect this", entries) |
| 64 | if len(decision.Candidates) != 1 { |
| 65 | t.Fatalf("candidates = %+v, want exactly the suggest skill", decision.Candidates) |
| 66 | } |
| 67 | if got := decision.Candidates[0]; got.Entry.ID != "skill:gentle" || got.Policy != AutoUseSuggest { |
| 68 | t.Fatalf("candidate = %+v, want gentle/suggest", got) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestRouteKeepsAllStrongCandidatesBeforeSuggestBudget(t *testing.T) { |
| 73 | entries := make([]Entry, 0, 8) |
| 74 | for i := 0; i < 6; i++ { |
| 75 | entries = append(entries, Entry{ID: fmt.Sprintf("skill:required-%d", i), Kind: KindSkill, Name: fmt.Sprintf("required-%d", i), AutoUse: AutoUsePrefer, Triggers: []string{"ship"}}) |
| 76 | } |
| 77 | entries = append(entries, |
| 78 | Entry{ID: "skill:suggest-a", Kind: KindSkill, Name: "suggest-a", AutoUse: AutoUseSuggest, Triggers: []string{"ship"}}, |
| 79 | Entry{ID: "skill:suggest-b", Kind: KindSkill, Name: "suggest-b", AutoUse: AutoUseSuggest, Triggers: []string{"ship"}}, |
| 80 | ) |
| 81 | |
| 82 | decision := Route("ship this", entries) |
| 83 | if len(decision.Candidates) != 6 { |
| 84 | t.Fatalf("candidates = %d, want all 6 strong candidates", len(decision.Candidates)) |
| 85 | } |
| 86 | for _, candidate := range decision.Candidates { |
| 87 | if candidate.Policy != AutoUsePrefer { |
| 88 | t.Fatalf("suggest candidate displaced a strong candidate: %+v", candidate) |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func TestRouteDeliveryPromotesMatchedBuiltinSkills(t *testing.T) { |
| 94 | entries := []Entry{ |
| 95 | {ID: "skill:explore", Kind: KindSkill, Name: "explore", Source: string(skill.ScopeBuiltin), AutoUse: AutoUseSuggest, Triggers: []string{"调用链"}}, |
| 96 | {ID: "skill:custom", Kind: KindSkill, Name: "custom", Source: string(skill.ScopeProject), AutoUse: AutoUseSuggest, Triggers: []string{"调用链"}}, |
| 97 | } |
| 98 | decision := RouteDelivery("分析调用链", entries) |
| 99 | if !decision.Delivery || len(decision.Candidates) != 2 { |
| 100 | t.Fatalf("delivery decision = %+v", decision) |
| 101 | } |
| 102 | if decision.Candidates[0].Entry.ID != "skill:explore" || decision.Candidates[0].Policy != AutoUsePrefer { |
| 103 | t.Fatalf("built-in candidate was not promoted: %+v", decision.Candidates) |
| 104 | } |
| 105 | if decision.Candidates[1].Entry.ID != "skill:custom" || decision.Candidates[1].Policy != AutoUseSuggest { |
| 106 | t.Fatalf("custom authored policy changed: %+v", decision.Candidates) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestRoutePrefersGitHubMCPForIssueLookup(t *testing.T) { |
| 111 | entries := ToolEntries([]tool.ContractEntry{{ |
| 112 | Name: "mcp__github__search_issues", |
| 113 | Description: "search GitHub issues", |
| 114 | ReadOnly: true, |
| 115 | }}) |
| 116 | |
| 117 | decision := Route("查一下 GitHub issue 里有没有相关反馈", entries) |
| 118 | if len(decision.Candidates) == 0 { |
| 119 | t.Fatal("Route returned no candidates") |
| 120 | } |
| 121 | got := decision.Candidates[0] |
| 122 | if got.Entry.ID != "mcp-tool:github/search_issues" || got.Policy != AutoUsePrefer { |
| 123 | t.Fatalf("candidate = %+v, want github mcp/prefer", got) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | func TestRenderTransientBlockMentionsConnectSource(t *testing.T) { |
| 128 | decision := RouteDecision{Candidates: []RouteCandidate{{ |
| 129 | Entry: Entry{ |
| 130 | ID: "skill:review", |
| 131 | Kind: KindSkill, |
| 132 | Name: "review", |
| 133 | Status: StatusConfigured, |
| 134 | ConnectSource: "skills", |
| 135 | }, |
| 136 | Policy: AutoUsePrefer, |
| 137 | Reason: "matched", |
| 138 | }}} |
| 139 | |
| 140 | block := RenderTransientBlock(decision) |
| 141 | for _, want := range []string{`<capability-route version="1">`, `source:skills`, `connect_tool_source`} { |
| 142 | if !strings.Contains(block, want) { |
| 143 | t.Fatalf("block missing %q:\n%s", want, block) |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 |