返回 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"}})
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
259 lines GO