| 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 TestRouteDoesNotTreatTextMentionAsInvocation(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 | for _, text := range []string{"/audit 检查一下", "then run /audit", "请使用 audit skill 检查一下"} { |
| 37 | decision := Route(text, entries) |
| 38 | if len(decision.Candidates) != 0 { |
| 39 | t.Errorf("%q must not create a structural skill invocation: %+v", text, decision.Candidates) |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestRouteRespectsSkillAutoUseMetadata(t *testing.T) { |
| 45 | entries := SkillEntries([]skill.Skill{ |
| 46 | { |
| 47 | Name: "quiet", |
| 48 | Description: "quiet skill", |
| 49 | Scope: skill.ScopeProject, |
| 50 | Triggers: []string{"inspect"}, |
| 51 | AutoUse: "off", |
| 52 | }, |
| 53 | { |
| 54 | Name: "gentle", |
| 55 | Description: "gentle skill", |
| 56 | Scope: skill.ScopeProject, |
| 57 | Triggers: []string{"inspect"}, |
| 58 | AutoUse: "suggest", |
| 59 | }, |
| 60 | }, []tool.ContractEntry{{Name: "run_skill"}}) |
| 61 | |
| 62 | decision := Route("please inspect this", entries) |
| 63 | if len(decision.Candidates) != 1 { |
| 64 | t.Fatalf("candidates = %+v, want exactly the suggest skill", decision.Candidates) |
| 65 | } |
| 66 | if got := decision.Candidates[0]; got.Entry.ID != "skill:gentle" || got.Policy != AutoUseSuggest { |
| 67 | t.Fatalf("candidate = %+v, want gentle/suggest", got) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestRouteKeepsAllStrongCandidatesBeforeSuggestBudget(t *testing.T) { |
| 72 | entries := make([]Entry, 0, 8) |
| 73 | for i := range 6 { |
| 74 | entries = append(entries, Entry{ID: fmt.Sprintf("skill:required-%d", i), Kind: KindSkill, Name: fmt.Sprintf("required-%d", i), AutoUse: AutoUsePrefer, Triggers: []string{"ship"}}) |
| 75 | } |
| 76 | entries = append(entries, |
| 77 | Entry{ID: "skill:suggest-a", Kind: KindSkill, Name: "suggest-a", AutoUse: AutoUseSuggest, Triggers: []string{"ship"}}, |
| 78 | Entry{ID: "skill:suggest-b", Kind: KindSkill, Name: "suggest-b", AutoUse: AutoUseSuggest, Triggers: []string{"ship"}}, |
| 79 | ) |
| 80 | |
| 81 | decision := Route("ship this", entries) |
| 82 | if len(decision.Candidates) != 6 { |
| 83 | t.Fatalf("candidates = %d, want all 6 strong candidates", len(decision.Candidates)) |
| 84 | } |
| 85 | for _, candidate := range decision.Candidates { |
| 86 | if candidate.Policy != AutoUsePrefer { |
| 87 | t.Fatalf("suggest candidate displaced a strong candidate: %+v", candidate) |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestRouteClosedLoopPromotesMatchedBuiltinSkills(t *testing.T) { |
| 93 | entries := []Entry{ |
| 94 | {ID: "skill:explore", Kind: KindSkill, Name: "explore", Source: string(skill.ScopeBuiltin), AutoUse: AutoUseSuggest, Triggers: []string{"调用链"}}, |
| 95 | {ID: "skill:custom", Kind: KindSkill, Name: "custom", Source: string(skill.ScopeProject), AutoUse: AutoUseSuggest, Triggers: []string{"调用链"}}, |
| 96 | } |
| 97 | decision := RouteClosedLoop("分析调用链", entries) |
| 98 | if !decision.ClosedLoop || len(decision.Candidates) != 2 { |
| 99 | t.Fatalf("delivery decision = %+v", decision) |
| 100 | } |
| 101 | if decision.Candidates[0].Entry.ID != "skill:explore" || decision.Candidates[0].Policy != AutoUsePrefer { |
| 102 | t.Fatalf("built-in candidate was not promoted: %+v", decision.Candidates) |
| 103 | } |
| 104 | if decision.Candidates[1].Entry.ID != "skill:custom" || decision.Candidates[1].Policy != AutoUseSuggest { |
| 105 | t.Fatalf("custom authored policy changed: %+v", decision.Candidates) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // Mentioning a vendor or asking for fresh data names no tool: a prefer tells |
| 110 | // the model to use it unless clearly unnecessary, so a server exposing forty |
| 111 | // tools must not put forty of them in front of every GitHub question. |
| 112 | func TestRouteDoesNotFloodOneMCPServer(t *testing.T) { |
| 113 | var contracts []tool.ContractEntry |
| 114 | for i := range 40 { |
| 115 | contracts = append(contracts, tool.ContractEntry{Name: fmt.Sprintf("mcp__github__tool_%d", i), ReadOnly: true}) |
| 116 | } |
| 117 | contracts = append(contracts, |
| 118 | tool.ContractEntry{Name: "mcp__docs__search_pages", ReadOnly: true}, |
| 119 | tool.ContractEntry{Name: "mcp__web__fetch_url", ReadOnly: true}, |
| 120 | tool.ContractEntry{Name: "mcp__notes__read_note", ReadOnly: true}, |
| 121 | ) |
| 122 | entries := ToolEntries(contracts) |
| 123 | |
| 124 | for _, input := range []string{ |
| 125 | "can you look at this github issue and the latest pr comments", |
| 126 | "查一下 GitHub issue 里有没有相关反馈", |
| 127 | "use the github mcp to check the recent issues", |
| 128 | } { |
| 129 | if decision := Route(input, entries); len(decision.Candidates) != 0 { |
| 130 | t.Fatalf("Route(%q) nominated %d tools nobody named, first %s", input, len(decision.Candidates), decision.Candidates[0].Entry.ID) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | decision := Route("call mcp__github__tool_7 on this issue", entries) |
| 135 | if len(decision.Candidates) != 1 { |
| 136 | t.Fatalf("named tool routed %d candidates, want 1: %+v", len(decision.Candidates), decision.Candidates) |
| 137 | } |
| 138 | if got := decision.Candidates[0]; got.Entry.ID != "mcp-tool:github/tool_7" || got.Policy != AutoUsePrefer { |
| 139 | t.Fatalf("candidate = %+v, want github/tool_7 prefer", got) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func TestRouteDoesNotPreferFailedCachedMCPTool(t *testing.T) { |
| 144 | entries := []Entry{{ |
| 145 | ID: "mcp-tool:github/search_issues", |
| 146 | Kind: KindMCPTool, |
| 147 | Name: "github/search_issues", |
| 148 | Source: "github", |
| 149 | ToolName: "mcp__github__search_issues", |
| 150 | Status: StatusFailed, |
| 151 | ConnectSource: "mcp", |
| 152 | ConnectName: "github", |
| 153 | }} |
| 154 | |
| 155 | decision := Route("用 mcp__github__search_issues 查一下相关反馈", entries) |
| 156 | if len(decision.Candidates) != 0 { |
| 157 | t.Fatalf("failed cached MCP tool was still routed: %+v", decision.Candidates) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func TestRenderTransientBlockMentionsConnectSource(t *testing.T) { |
| 162 | decision := RouteDecision{Candidates: []RouteCandidate{{ |
| 163 | Entry: Entry{ |
| 164 | ID: "skill:review", |
| 165 | Kind: KindSkill, |
| 166 | Name: "review", |
| 167 | Status: StatusConfigured, |
| 168 | ConnectSource: "skills", |
| 169 | }, |
| 170 | Policy: AutoUsePrefer, |
| 171 | Reason: "matched", |
| 172 | }}} |
| 173 | |
| 174 | block := RenderTransientBlock(decision) |
| 175 | for _, want := range []string{`<capability-route version="1">`, `source:skills`, `connect_tool_source`} { |
| 176 | if !strings.Contains(block, want) { |
| 177 | t.Fatalf("block missing %q:\n%s", want, block) |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 |