| 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"}}) |
| 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 | Profile: ProfileDelivery, |
| 55 | CachedTools: cached, |
| 56 | CacheKeyOK: keyOK, |
| 57 | }) |
| 58 | byID := map[string]Entry{} |
| 59 | for _, e := range cat.Entries { |
| 60 | byID[e.ID] = e |
| 61 | } |
| 62 | toolEntry, ok := byID["mcp-tool:gh/search_issues"] |
| 63 | if !ok { |
| 64 | t.Fatalf("cached tool missing from catalog: %v", cat.Entries) |
| 65 | } |
| 66 | if !toolEntry.ReadOnly || toolEntry.ToolName == "" { |
| 67 | t.Fatalf("cached tool entry lost metadata: %+v", toolEntry) |
| 68 | } |
| 69 | if server := byID["mcp-server:old"]; server.Status != StatusStale { |
| 70 | t.Fatalf("cache-key-mismatched schema should mark the server stale, got %q", server.Status) |
| 71 | } |
| 72 | if staleTool, ok := byID["mcp-tool:old/do_thing"]; !ok { |
| 73 | t.Fatal("stale cached tools should still appear as candidates") |
| 74 | } else if staleTool.Status != StatusStale { |
| 75 | t.Fatalf("stale server's cached tools must inherit stale, got %q", staleTool.Status) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestRecordRouterUsageAccumulates(t *testing.T) { |
| 80 | a := &Audit{} |
| 81 | a.RecordRouterUsage(100, 20, 0.005, 340) |
| 82 | a.RecordRouterUsage(50, 10, 0.002, 160) |
| 83 | snap := a.Snapshot() |
| 84 | if snap.RouterPromptTokens != 150 || snap.RouterCompletionTokens != 30 { |
| 85 | t.Fatalf("token counters: prompt=%d completion=%d", snap.RouterPromptTokens, snap.RouterCompletionTokens) |
| 86 | } |
| 87 | if snap.RouterCost < 0.0069 || snap.RouterCost > 0.0071 { |
| 88 | t.Fatalf("cost = %v", snap.RouterCost) |
| 89 | } |
| 90 | if snap.RouterLatencyMs != 500 { |
| 91 | t.Fatalf("latency = %v", snap.RouterLatencyMs) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestAuditRecordsDecisionFunnelAndDecline(t *testing.T) { |
| 96 | a := &Audit{} |
| 97 | a.RecordDecision(RouteDecision{Candidates: []RouteCandidate{ |
| 98 | {Policy: AutoUseRequire}, |
| 99 | {Policy: AutoUsePrefer}, |
| 100 | {Policy: AutoUseSuggest}, |
| 101 | }}) |
| 102 | a.RecordDecline() |
| 103 | snap := a.Snapshot() |
| 104 | if snap.RoutedCandidates != 3 || snap.RoutedRequire != 1 || snap.RoutedPrefer != 1 || snap.RoutedSuggest != 1 || snap.Declines != 1 { |
| 105 | t.Fatalf("decision funnel audit: candidates=%d require=%d prefer=%d suggest=%d declines=%d", |
| 106 | snap.RoutedCandidates, snap.RoutedRequire, snap.RoutedPrefer, snap.RoutedSuggest, snap.Declines) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestDeliveryRouteRenderKeepsCapabilityIDAndProxyInstruction(t *testing.T) { |
| 111 | entry := Entry{ |
| 112 | ID: "mcp-tool:gh/search_issues", Kind: KindMCPTool, Name: "gh/search_issues", |
| 113 | Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "gh", |
| 114 | } |
| 115 | d := RouteDecision{Delivery: true, Candidates: []RouteCandidate{{Entry: entry, Policy: AutoUsePrefer, Reason: "matches task"}}} |
| 116 | out := RenderTransientBlock(d) |
| 117 | if !strings.Contains(out, "mcp-tool:gh/search_issues") { |
| 118 | t.Fatalf("delivery render must keep the concrete capability id:\n%s", out) |
| 119 | } |
| 120 | if !strings.Contains(out, `use_capability(action="call", capability_id="mcp-tool:gh/search_issues"`) { |
| 121 | t.Fatalf("delivery render must instruct the proxy call:\n%s", out) |
| 122 | } |
| 123 | if strings.Contains(out, "connect_tool_source") { |
| 124 | t.Fatalf("connect_tool_source is not registered in Delivery:\n%s", out) |
| 125 | } |
| 126 | // Server entries direct the model to connect-and-list via the same proxy. |
| 127 | server := Entry{ID: "mcp-server:gh", Kind: KindMCPServer, Name: "gh", Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "gh"} |
| 128 | out = RenderTransientBlock(RouteDecision{Delivery: true, Candidates: []RouteCandidate{{Entry: server, Policy: AutoUseSuggest, Reason: "r"}}}) |
| 129 | if !strings.Contains(out, `use_capability(action="call", capability_id="mcp-server:gh")`) || !strings.Contains(out, "list its tools") { |
| 130 | t.Fatalf("server candidate must instruct connect-and-list:\n%s", out) |
| 131 | } |
| 132 | // Non-delivery keeps the historical connect_tool_source instruction. |
| 133 | d.Delivery = false |
| 134 | out = RenderTransientBlock(d) |
| 135 | if !strings.Contains(out, "connect_tool_source") { |
| 136 | t.Fatalf("non-delivery render lost connect_tool_source:\n%s", out) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | func TestCapabilityProxyRouteRenderKeepsConcreteMCPIDs(t *testing.T) { |
| 141 | for _, entry := range []Entry{ |
| 142 | {ID: "mcp-tool:gh/search_issues", Kind: KindMCPTool, Name: "gh/search_issues", Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "gh"}, |
| 143 | {ID: "mcp-server:gh", Kind: KindMCPServer, Name: "gh", Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "gh"}, |
| 144 | } { |
| 145 | out := RenderTransientBlock(RouteDecision{ |
| 146 | CapabilityProxy: true, |
| 147 | Candidates: []RouteCandidate{{Entry: entry, Policy: AutoUsePrefer, Reason: "matches task"}}, |
| 148 | }) |
| 149 | if !strings.Contains(out, "- "+entry.ID+" ") { |
| 150 | t.Fatalf("capability proxy route must lead with the concrete id %q:\n%s", entry.ID, out) |
| 151 | } |
| 152 | if strings.Contains(out, "source:mcp/gh") { |
| 153 | t.Fatalf("capability proxy route rewrote %q to an unusable source target:\n%s", entry.ID, out) |
| 154 | } |
| 155 | if !strings.Contains(out, `use_capability(action="call", capability_id="`+entry.ID+`"`) { |
| 156 | t.Fatalf("capability proxy route lost the concrete call instruction for %q:\n%s", entry.ID, out) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // CapabilityProxy only replaces the MCP connector. Other configured |
| 161 | // capability kinds still use their ordinary source routing. |
| 162 | skill := Entry{ID: "skill:review", Kind: KindSkill, Name: "review", Status: StatusConfigured, ConnectSource: "skills"} |
| 163 | out := RenderTransientBlock(RouteDecision{ |
| 164 | CapabilityProxy: true, |
| 165 | Candidates: []RouteCandidate{{Entry: skill, Policy: AutoUseSuggest, Reason: "matches task"}}, |
| 166 | }) |
| 167 | if !strings.Contains(out, "source:skills") || !strings.Contains(out, "connect_tool_source") { |
| 168 | t.Fatalf("MCP proxy routing changed the ordinary skill connector:\n%s", out) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestOrdinaryRouteRenderDeduplicatesCollapsedMCPSourceLines(t *testing.T) { |
| 173 | candidates := []RouteCandidate{ |
| 174 | { |
| 175 | Entry: Entry{ |
| 176 | ID: "mcp-tool:search/search", Kind: KindMCPTool, Name: "search/search", |
| 177 | Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "search", |
| 178 | }, |
| 179 | Policy: AutoUsePrefer, Reason: "the task appears to need fresh external data", |
| 180 | }, |
| 181 | { |
| 182 | Entry: Entry{ |
| 183 | ID: "mcp-tool:search/fetch", Kind: KindMCPTool, Name: "search/fetch", |
| 184 | Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "search", |
| 185 | }, |
| 186 | Policy: AutoUsePrefer, Reason: "the task appears to need fresh external data", |
| 187 | }, |
| 188 | { |
| 189 | Entry: Entry{ |
| 190 | ID: "mcp-tool:docs/read", Kind: KindMCPTool, Name: "docs/read", |
| 191 | Status: StatusConfigured, ConnectSource: "mcp", ConnectName: "docs", |
| 192 | }, |
| 193 | Policy: AutoUsePrefer, Reason: "the task appears to need fresh external data", |
| 194 | }, |
| 195 | } |
| 196 | |
| 197 | out := RenderTransientBlock(RouteDecision{Candidates: candidates}) |
| 198 | if got := strings.Count(out, "- source:mcp/search "); got != 1 { |
| 199 | t.Fatalf("collapsed MCP source rendered %d times, want 1:\n%s", got, out) |
| 200 | } |
| 201 | if got := strings.Count(out, "- source:mcp/docs "); got != 1 { |
| 202 | t.Fatalf("independent MCP source rendered %d times, want 1:\n%s", got, out) |
| 203 | } |
| 204 | |
| 205 | for _, decision := range []RouteDecision{ |
| 206 | {Delivery: true, Candidates: candidates}, |
| 207 | {CapabilityProxy: true, Candidates: candidates}, |
| 208 | } { |
| 209 | proxyOut := RenderTransientBlock(decision) |
| 210 | for _, candidate := range candidates { |
| 211 | if !strings.Contains(proxyOut, "- "+candidate.Entry.ID+" ") { |
| 212 | t.Fatalf("proxy route lost concrete capability %q:\n%s", candidate.Entry.ID, proxyOut) |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | func TestCatalogKeepsProxyToolsAfterConnect(t *testing.T) { |
| 219 | proxy := map[string][]plugin.CachedTool{ |
| 220 | "gh": {{Name: "search_issues", Description: "search", ReadOnly: true}}, |
| 221 | } |
| 222 | cat := BuildCatalog(CatalogOptions{ |
| 223 | Plugins: []config.PluginEntry{{Name: "gh", AutoStart: boolPtr(false)}}, |
| 224 | Profile: ProfileDelivery, |
| 225 | Connected: map[string]bool{"gh": true}, // server is ready now |
| 226 | ProxyTools: proxy, |
| 227 | }) |
| 228 | byID := map[string]Entry{} |
| 229 | for _, e := range cat.Entries { |
| 230 | byID[e.ID] = e |
| 231 | } |
| 232 | toolEntry, ok := byID["mcp-tool:gh/search_issues"] |
| 233 | if !ok { |
| 234 | t.Fatalf("proxy-connected tool vanished from catalog: %+v", cat.Entries) |
| 235 | } |
| 236 | if toolEntry.Status != StatusReady { |
| 237 | t.Fatalf("proxy-connected tool should be ready, got %q", toolEntry.Status) |
| 238 | } |
| 239 | // When the same server's tools are already on the registry, no duplicates. |
| 240 | cat = BuildCatalog(CatalogOptions{ |
| 241 | Tools: []tool.ContractEntry{{Name: plugin.ModelToolName("gh", "search_issues")}}, |
| 242 | Plugins: []config.PluginEntry{{Name: "gh", AutoStart: boolPtr(false)}}, |
| 243 | Profile: ProfileDelivery, |
| 244 | Connected: map[string]bool{"gh": true}, |
| 245 | ProxyTools: proxy, |
| 246 | }) |
| 247 | count := 0 |
| 248 | for _, e := range cat.Entries { |
| 249 | if e.ID == "mcp-tool:gh/search_issues" { |
| 250 | count++ |
| 251 | } |
| 252 | } |
| 253 | // The registry's own ToolEntries contribution is the single source here; |
| 254 | // the proxy snapshot must not add a duplicate. |
| 255 | if count != 1 { |
| 256 | t.Fatalf("registry-backed server should have exactly one catalog entry, got %d", count) |
| 257 | } |
| 258 | } |
| 259 |