| 1 | package capability |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "reasonix/internal/config" |
| 8 | "reasonix/internal/plugin" |
| 9 | "reasonix/internal/tool" |
| 10 | ) |
| 11 | |
| 12 | func boolPtr(b bool) *bool { return &b } |
| 13 | |
| 14 | func TestLoadCachedToolsForSpecsHonorsSchemaCacheKey(t *testing.T) { |
| 15 | t.Setenv("REASONIX_CACHE_HOME", t.TempDir()) |
| 16 | fresh := plugin.Spec{Name: "gh", Type: "stdio", Command: "gh-mcp"} |
| 17 | if err := plugin.SaveCachedSchema("gh", plugin.CachedSchema{ |
| 18 | CacheKey: plugin.SchemaCacheKey(fresh), |
| 19 | Tools: []plugin.CachedTool{{Name: "search_issues", Description: "search", ReadOnly: true}}, |
| 20 | }); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | stale := plugin.Spec{Name: "old", Type: "stdio", Command: "old-mcp"} |
| 24 | if err := plugin.SaveCachedSchema("old", plugin.CachedSchema{ |
| 25 | CacheKey: "some-other-cache-key", |
| 26 | Tools: []plugin.CachedTool{{Name: "do_thing"}}, |
| 27 | }); err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | |
| 31 | cached, keyOK := LoadCachedToolsForSpecs([]plugin.Spec{fresh, stale, {Name: "absent"}}, plugin.HostProfileCore) |
| 32 | if len(cached["gh"]) != 1 || !keyOK["gh"] { |
| 33 | t.Fatalf("fresh cache: tools=%v keyOK=%v", cached["gh"], keyOK["gh"]) |
| 34 | } |
| 35 | if len(cached["old"]) != 1 || keyOK["old"] { |
| 36 | t.Fatalf("stale cache must load with keyOK=false: tools=%v keyOK=%v", cached["old"], keyOK["old"]) |
| 37 | } |
| 38 | if _, ok := cached["absent"]; ok { |
| 39 | t.Fatal("server without cache must be absent") |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestBuildCatalogSurfacesCachedToolsForAutoStartFalse(t *testing.T) { |
| 44 | cached := map[string][]plugin.CachedTool{ |
| 45 | "gh": {{Name: "search_issues", Description: "search", ReadOnly: true}}, |
| 46 | "old": {{Name: "do_thing"}}, |
| 47 | } |
| 48 | keyOK := map[string]bool{"gh": true, "old": false} |
| 49 | cat := BuildCatalog(CatalogOptions{ |
| 50 | Plugins: []config.PluginEntry{ |
| 51 | {Name: "gh", AutoStart: boolPtr(false)}, |
| 52 | {Name: "old", AutoStart: boolPtr(false)}, |
| 53 | }, |
| 54 | CachedTools: cached, |
| 55 | CacheKeyOK: keyOK, |
| 56 | }) |
| 57 | byID := map[string]Entry{} |
| 58 | for _, e := range cat.Entries { |
| 59 | byID[e.ID] = e |
| 60 | } |
| 61 | toolEntry, ok := byID["mcp-tool:gh/search_issues"] |
| 62 | if !ok { |
| 63 | t.Fatalf("cached tool missing from catalog: %v", cat.Entries) |
| 64 | } |
| 65 | if !toolEntry.ReadOnly || toolEntry.ToolName == "" { |
| 66 | t.Fatalf("cached tool entry lost metadata: %+v", toolEntry) |
| 67 | } |
| 68 | if server := byID["mcp-server:old"]; server.Status != StatusStale { |
| 69 | t.Fatalf("cache-key-mismatched schema should mark the server stale, got %q", server.Status) |
| 70 | } |
| 71 | if staleTool, ok := byID["mcp-tool:old/do_thing"]; !ok { |
| 72 | t.Fatal("stale cached tools should still appear as candidates") |
| 73 | } else if staleTool.Status != StatusStale { |
| 74 | t.Fatalf("stale server's cached tools must inherit stale, got %q", staleTool.Status) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | func TestRecordRouterUsageAccumulates(t *testing.T) { |
| 79 | a := &Audit{} |
| 80 | a.RecordRouterUsage(100, 20, 0.005, 340) |
| 81 | a.RecordRouterUsage(50, 10, 0.002, 160) |
| 82 | snap := a.Snapshot() |
| 83 | if snap.RouterPromptTokens != 150 || snap.RouterCompletionTokens != 30 { |
| 84 | t.Fatalf("token counters: prompt=%d completion=%d", snap.RouterPromptTokens, snap.RouterCompletionTokens) |
| 85 | } |
| 86 | if snap.RouterCost < 0.0069 || snap.RouterCost > 0.0071 { |
| 87 | t.Fatalf("cost = %v", snap.RouterCost) |
| 88 | } |
| 89 | if snap.RouterLatencyMs != 500 { |
| 90 | t.Fatalf("latency = %v", snap.RouterLatencyMs) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | func TestAuditRecordsDecisionFunnelAndDecline(t *testing.T) { |
| 95 | a := &Audit{} |
| 96 | a.RecordDecision(RouteDecision{Candidates: []RouteCandidate{ |
| 97 | {Policy: AutoUseRequire}, |
| 98 | {Policy: AutoUsePrefer}, |
| 99 | {Policy: AutoUseSuggest}, |
| 100 | }}) |
| 101 | a.RecordDecline() |
| 102 | snap := a.Snapshot() |
| 103 | if snap.RoutedCandidates != 3 || snap.RoutedRequire != 1 || snap.RoutedPrefer != 1 || snap.RoutedSuggest != 1 || snap.Declines != 1 { |
| 104 | t.Fatalf("decision funnel audit: candidates=%d require=%d prefer=%d suggest=%d declines=%d", |
| 105 | snap.RoutedCandidates, snap.RoutedRequire, snap.RoutedPrefer, snap.RoutedSuggest, snap.Declines) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func TestDeliveryRouteRenderKeepsCapabilityIDAndProxyInstruction(t *testing.T) { |
| 110 | entry := Entry{ |
| 111 | ID: "mcp-tool:gh/search_issues", Kind: KindMCPTool, Name: "gh/search_issues", |
| 112 | Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "gh", |
| 113 | } |
| 114 | d := RouteDecision{ClosedLoop: true, Candidates: []RouteCandidate{{Entry: entry, Policy: AutoUsePrefer, Reason: "matches task"}}} |
| 115 | out := RenderTransientBlock(d) |
| 116 | if !strings.Contains(out, "mcp-tool:gh/search_issues") { |
| 117 | t.Fatalf("delivery render must keep the concrete capability id:\n%s", out) |
| 118 | } |
| 119 | if !strings.Contains(out, `use_capability(action="call", capability_id="mcp-tool:gh/search_issues"`) { |
| 120 | t.Fatalf("delivery render must instruct the proxy call:\n%s", out) |
| 121 | } |
| 122 | if strings.Contains(out, "connect_tool_source") { |
| 123 | t.Fatalf("connect_tool_source is not registered in Delivery:\n%s", out) |
| 124 | } |
| 125 | // Server entries direct the model to connect-and-list via the same proxy. |
| 126 | server := Entry{ID: "mcp-server:gh", Kind: KindMCPServer, Name: "gh", Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "gh"} |
| 127 | out = RenderTransientBlock(RouteDecision{ClosedLoop: true, Candidates: []RouteCandidate{{Entry: server, Policy: AutoUseSuggest, Reason: "r"}}}) |
| 128 | if !strings.Contains(out, `use_capability(action="call", capability_id="mcp-server:gh")`) || !strings.Contains(out, "list its tools") { |
| 129 | t.Fatalf("server candidate must instruct connect-and-list:\n%s", out) |
| 130 | } |
| 131 | // Non-delivery keeps the historical connect_tool_source instruction. |
| 132 | d.ClosedLoop = false |
| 133 | out = RenderTransientBlock(d) |
| 134 | if !strings.Contains(out, "connect_tool_source") { |
| 135 | t.Fatalf("non-delivery render lost connect_tool_source:\n%s", out) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func TestCapabilityProxyRouteRenderKeepsConcreteMCPIDs(t *testing.T) { |
| 140 | for _, entry := range []Entry{ |
| 141 | {ID: "mcp-tool:gh/search_issues", Kind: KindMCPTool, Name: "gh/search_issues", Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "gh"}, |
| 142 | {ID: "mcp-server:gh", Kind: KindMCPServer, Name: "gh", Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "gh"}, |
| 143 | } { |
| 144 | out := RenderTransientBlock(RouteDecision{ |
| 145 | CapabilityProxy: true, |
| 146 | Candidates: []RouteCandidate{{Entry: entry, Policy: AutoUsePrefer, Reason: "matches task"}}, |
| 147 | }) |
| 148 | if !strings.Contains(out, "- "+entry.ID+" ") { |
| 149 | t.Fatalf("capability proxy route must lead with the concrete id %q:\n%s", entry.ID, out) |
| 150 | } |
| 151 | if strings.Contains(out, "source:mcp/gh") { |
| 152 | t.Fatalf("capability proxy route rewrote %q to an unusable source target:\n%s", entry.ID, out) |
| 153 | } |
| 154 | if !strings.Contains(out, `use_capability(action="call", capability_id="`+entry.ID+`"`) { |
| 155 | t.Fatalf("capability proxy route lost the concrete call instruction for %q:\n%s", entry.ID, out) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // CapabilityProxy only replaces the MCP connector. Other configured |
| 160 | // capability kinds still use their ordinary source routing. |
| 161 | skill := Entry{ID: "skill:review", Kind: KindSkill, Name: "review", Status: StatusConfigured, ConnectSource: "skills"} |
| 162 | out := RenderTransientBlock(RouteDecision{ |
| 163 | CapabilityProxy: true, |
| 164 | Candidates: []RouteCandidate{{Entry: skill, Policy: AutoUseSuggest, Reason: "matches task"}}, |
| 165 | }) |
| 166 | if !strings.Contains(out, "source:skills") || !strings.Contains(out, "connect_tool_source") { |
| 167 | t.Fatalf("MCP proxy routing changed the ordinary skill connector:\n%s", out) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | func TestMCPServerEntriesPropagatesFailureToCachedTools(t *testing.T) { |
| 172 | entries := MCPServerEntries(CatalogOptions{ |
| 173 | Plugins: []config.PluginEntry{{Name: "github", Type: "http", URL: "https://example.test/mcp"}}, |
| 174 | Failed: map[string]string{"github": "http 401"}, |
| 175 | CachedTools: map[string][]plugin.CachedTool{ |
| 176 | "github": {{Name: "search_issues", Description: "search issues", ReadOnly: true}}, |
| 177 | }, |
| 178 | }) |
| 179 | |
| 180 | for _, entry := range entries { |
| 181 | if entry.ID == "mcp-tool:github/search_issues" { |
| 182 | if entry.Status != StatusFailed { |
| 183 | t.Fatalf("cached tool status = %q, want %q", entry.Status, StatusFailed) |
| 184 | } |
| 185 | return |
| 186 | } |
| 187 | } |
| 188 | t.Fatal("cached MCP tool entry not found") |
| 189 | } |
| 190 | |
| 191 | func TestBuildCatalogUnavailableServerOverridesRegistryCachedTool(t *testing.T) { |
| 192 | const server = "github" |
| 193 | tests := []struct { |
| 194 | name string |
| 195 | failed map[string]string |
| 196 | disabled map[string]bool |
| 197 | want Status |
| 198 | reason string |
| 199 | }{ |
| 200 | {name: "failed", failed: map[string]string{server: "http 401"}, want: StatusFailed, reason: "http 401"}, |
| 201 | {name: "disabled", disabled: map[string]bool{server: true}, want: StatusDisabled}, |
| 202 | } |
| 203 | for _, tc := range tests { |
| 204 | t.Run(tc.name, func(t *testing.T) { |
| 205 | cat := BuildCatalog(CatalogOptions{ |
| 206 | Tools: []tool.ContractEntry{{ |
| 207 | Name: plugin.ModelToolName(server, "search_issues"), |
| 208 | Description: "search issues", |
| 209 | ReadOnly: true, |
| 210 | }}, |
| 211 | Plugins: []config.PluginEntry{{Name: server, Type: "http", URL: "https://example.test/mcp"}}, |
| 212 | Failed: tc.failed, |
| 213 | Disabled: tc.disabled, |
| 214 | CachedTools: map[string][]plugin.CachedTool{ |
| 215 | server: {{Name: "search_issues", Description: "search issues", ReadOnly: true}}, |
| 216 | }, |
| 217 | }) |
| 218 | |
| 219 | entry, ok := cat.Lookup("mcp-tool:github/search_issues") |
| 220 | if !ok { |
| 221 | t.Fatal("registry-backed cached MCP tool missing from catalog") |
| 222 | } |
| 223 | if entry.Status != tc.want || entry.FailureReason != tc.reason { |
| 224 | t.Fatalf("registry-backed cached tool = %+v, want status=%q reason=%q", entry, tc.want, tc.reason) |
| 225 | } |
| 226 | if decision := Route("查一下 GitHub issue", cat.Entries); len(decision.Candidates) != 0 { |
| 227 | t.Fatalf("unavailable registry-backed cached MCP tool was routed: %+v", decision.Candidates) |
| 228 | } |
| 229 | }) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestOrdinaryRouteRenderDeduplicatesCollapsedMCPSourceLines(t *testing.T) { |
| 234 | candidates := []RouteCandidate{ |
| 235 | { |
| 236 | Entry: Entry{ |
| 237 | ID: "mcp-tool:search/search", Kind: KindMCPTool, Name: "search/search", |
| 238 | Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "search", |
| 239 | }, |
| 240 | Policy: AutoUsePrefer, Reason: "the task appears to need fresh external data", |
| 241 | }, |
| 242 | { |
| 243 | Entry: Entry{ |
| 244 | ID: "mcp-tool:search/fetch", Kind: KindMCPTool, Name: "search/fetch", |
| 245 | Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "search", |
| 246 | }, |
| 247 | Policy: AutoUsePrefer, Reason: "the task appears to need fresh external data", |
| 248 | }, |
| 249 | { |
| 250 | Entry: Entry{ |
| 251 | ID: "mcp-tool:docs/read", Kind: KindMCPTool, Name: "docs/read", |
| 252 | Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "docs", |
| 253 | }, |
| 254 | Policy: AutoUsePrefer, Reason: "the task appears to need fresh external data", |
| 255 | }, |
| 256 | } |
| 257 | |
| 258 | out := RenderTransientBlock(RouteDecision{Candidates: candidates}) |
| 259 | if got := strings.Count(out, "- source:mcp/search "); got != 1 { |
| 260 | t.Fatalf("collapsed MCP source rendered %d times, want 1:\n%s", got, out) |
| 261 | } |
| 262 | if got := strings.Count(out, "- source:mcp/docs "); got != 1 { |
| 263 | t.Fatalf("independent MCP source rendered %d times, want 1:\n%s", got, out) |
| 264 | } |
| 265 | |
| 266 | for _, decision := range []RouteDecision{ |
| 267 | {ClosedLoop: true, Candidates: candidates}, |
| 268 | {CapabilityProxy: true, Candidates: candidates}, |
| 269 | } { |
| 270 | proxyOut := RenderTransientBlock(decision) |
| 271 | for _, candidate := range candidates { |
| 272 | if !strings.Contains(proxyOut, "- "+candidate.Entry.ID+" ") { |
| 273 | t.Fatalf("proxy route lost concrete capability %q:\n%s", candidate.Entry.ID, proxyOut) |
| 274 | } |
| 275 | } |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | func TestCatalogKeepsProxyToolsAfterConnect(t *testing.T) { |
| 280 | proxy := map[string][]plugin.CachedTool{ |
| 281 | "gh": {{Name: "search_issues", Description: "search", ReadOnly: true}}, |
| 282 | } |
| 283 | cat := BuildCatalog(CatalogOptions{ |
| 284 | Plugins: []config.PluginEntry{{Name: "gh", AutoStart: boolPtr(false)}}, |
| 285 | Connected: map[string]bool{"gh": true}, // server is ready now |
| 286 | ProxyTools: proxy, |
| 287 | }) |
| 288 | byID := map[string]Entry{} |
| 289 | for _, e := range cat.Entries { |
| 290 | byID[e.ID] = e |
| 291 | } |
| 292 | toolEntry, ok := byID["mcp-tool:gh/search_issues"] |
| 293 | if !ok { |
| 294 | t.Fatalf("proxy-connected tool vanished from catalog: %+v", cat.Entries) |
| 295 | } |
| 296 | if toolEntry.Status != StatusReady { |
| 297 | t.Fatalf("proxy-connected tool should be ready, got %q", toolEntry.Status) |
| 298 | } |
| 299 | // When the same server's tools are already on the registry, no duplicates. |
| 300 | cat = BuildCatalog(CatalogOptions{ |
| 301 | Tools: []tool.ContractEntry{{Name: plugin.ModelToolName("gh", "search_issues")}}, |
| 302 | Plugins: []config.PluginEntry{{Name: "gh", AutoStart: boolPtr(false)}}, |
| 303 | Connected: map[string]bool{"gh": true}, |
| 304 | ProxyTools: proxy, |
| 305 | }) |
| 306 | count := 0 |
| 307 | for _, e := range cat.Entries { |
| 308 | if e.ID == "mcp-tool:gh/search_issues" { |
| 309 | count++ |
| 310 | } |
| 311 | } |
| 312 | // The registry's own ToolEntries contribution is the single source here; |
| 313 | // the proxy snapshot must not add a duplicate. |
| 314 | if count != 1 { |
| 315 | t.Fatalf("registry-backed server should have exactly one catalog entry, got %d", count) |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | func TestCatalogDoesNotRouteProxyToolsAfterFailure(t *testing.T) { |
| 320 | cat := BuildCatalog(CatalogOptions{ |
| 321 | Plugins: []config.PluginEntry{{Name: "gh", AutoStart: boolPtr(false)}}, |
| 322 | Failed: map[string]string{"gh": "connection reset"}, |
| 323 | Connected: map[string]bool{"gh": true}, |
| 324 | CachedTools: map[string][]plugin.CachedTool{"gh": {{Name: "search_issues"}}}, |
| 325 | ProxyTools: map[string][]plugin.CachedTool{"gh": {{Name: "search_issues"}}}, |
| 326 | }) |
| 327 | entry, ok := cat.Lookup("mcp-tool:gh/search_issues") |
| 328 | if !ok || entry.Status != StatusFailed { |
| 329 | t.Fatalf("failed server proxy tool = (%+v, %v), want failed catalog entry", entry, ok) |
| 330 | } |
| 331 | } |
| 332 |