返回 DeepSeek-Reasonix
catalog_cache_test.go
根目录 / internal / capability / catalog_cache_test.go
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
332 lines GO