返回 DeepSeek-Reasonix
usecapability_list_test.go
根目录 / internal / agent / usecapability_list_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "testing"
9
10 "reasonix/internal/capability"
11 "reasonix/internal/config"
12 "reasonix/internal/plugin"
13 "reasonix/internal/skill"
14 "reasonix/internal/tool"
15 )
16
17 func TestUseCapabilityListSummarizesMCPWithoutExpandingCachedDirectories(t *testing.T) {
18 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
19 specs := []plugin.Spec{
20 {Name: "disabled", Type: "stdio", Command: "disabled-mcp", Authorized: true},
21 {Name: "enabled", Type: "stdio", Command: "enabled-mcp", Authorized: true},
22 }
23 entries := []config.PluginEntry{
24 {Name: "disabled", Type: "stdio", Command: "disabled-mcp"},
25 {Name: "enabled", Type: "stdio", Command: "enabled-mcp"},
26 }
27 for _, spec := range specs {
28 cached := make([]plugin.CachedTool, 64)
29 for i := range cached {
30 cached[i] = plugin.CachedTool{
31 Name: fmt.Sprintf("tool_%03d", i),
32 Description: fmt.Sprintf("catalog-bloat-sentinel-%s-%03d-%s", spec.Name, i, strings.Repeat("x", 256)),
33 ReadOnly: true,
34 }
35 }
36 if err := plugin.SaveCachedSchema(spec.Name, plugin.CachedSchema{
37 CacheKey: plugin.SchemaCacheKey(spec),
38 Tools: cached,
39 }); err != nil {
40 t.Fatal(err)
41 }
42 }
43
44 host := plugin.NewHost()
45 defer host.Close()
46 reg := tool.NewRegistry()
47 var runtime *MCPCapabilityRuntime
48 catalogFn := func() capability.Catalog {
49 plugins, cached, keyOK, disabled, proxyTools := runtime.CapabilityCatalogState()
50 catalog := capability.BuildCatalog(capability.CatalogOptions{
51 Tools: reg.AllContractEntries(),
52 Plugins: plugins,
53 Disabled: disabled,
54 CachedTools: cached,
55 CacheKeyOK: keyOK,
56 ProxyTools: proxyTools,
57 })
58 catalog.Entries = append(catalog.Entries, capability.Entry{
59 ID: "skill:review", Kind: capability.KindSkill, Name: "review", Status: capability.StatusReady,
60 })
61 return catalog
62 }
63 runtime = NewMCPCapabilityRuntime(context.Background(), host, specs, reg, catalogFn)
64 runtime.ConfigureServers(entries, specs, map[string]bool{"enabled": true})
65 frontend := runtime.NewFrontend(nil, nil)
66
67 out, err := frontend.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
68 if err != nil {
69 t.Fatal(err)
70 }
71 var payload struct {
72 Capabilities []struct {
73 ID string `json:"id"`
74 Kind string `json:"kind"`
75 } `json:"capabilities"`
76 Servers []listServerInfo `json:"servers"`
77 }
78 if err := json.Unmarshal([]byte(out), &payload); err != nil {
79 t.Fatalf("decode list result: %v\n%s", err, out)
80 }
81 if len(payload.Capabilities) != 1 || payload.Capabilities[0].ID != "skill:review" {
82 t.Fatalf("list expanded MCP entries instead of keeping only non-MCP capabilities: %+v", payload.Capabilities)
83 }
84 if len(payload.Servers) != 2 || payload.Servers[0].Name != "disabled" || payload.Servers[0].Status != "disabled" || payload.Servers[1].Name != "enabled" || payload.Servers[1].Status != "configured" {
85 t.Fatalf("server summaries = %+v, want disabled/configured in stable name order", payload.Servers)
86 }
87 if strings.Contains(out, "catalog-bloat-sentinel") {
88 t.Fatalf("list leaked concrete MCP directory entries:\n%s", out)
89 }
90 if len(out) >= 4096 {
91 t.Fatalf("summary list grew with cached tool descriptions: bytes=%d", len(out))
92 }
93 t.Logf("compact list bytes=%d for %d cached MCP tools", len(out), len(specs)*64)
94
95 inspected, err := frontend.Execute(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"mcp-server:enabled"}`))
96 if err != nil || !strings.Contains(inspected, "catalog-bloat-sentinel-enabled-000") {
97 t.Fatalf("inspect did not preserve the selected server's cached directory: %v\n%s", err, inspected)
98 }
99 if host.HasClient("enabled") {
100 t.Fatal("inspect started the selected MCP server")
101 }
102 disabledInspect, err := frontend.Execute(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"mcp-server:disabled"}`))
103 if err != nil || !strings.Contains(disabledInspect, "disabled") || strings.Contains(disabledInspect, "catalog-bloat-sentinel") {
104 t.Fatalf("disabled inspect exposed a non-actionable cached directory: %v\n%s", err, disabledInspect)
105 }
106 }
107
108 func TestUseCapabilityListPagesOneImmutableCatalogVersion(t *testing.T) {
109 skills := make([]skill.Skill, 123)
110 for i := range skills {
111 skills[i] = skill.Skill{Name: fmt.Sprintf("skill-%03d", i), Description: "candidate", Scope: skill.ScopeProject}
112 }
113 revision := 0
114 catalogFn := func() capability.Catalog {
115 current := append([]skill.Skill(nil), skills...)
116 if revision > 0 {
117 current = append(current, skill.Skill{Name: "new", Description: "candidate", Scope: skill.ScopeProject})
118 }
119 return capability.BuildCatalog(capability.CatalogOptions{Skills: current})
120 }
121 runtime := NewMCPCapabilityRuntime(context.Background(), nil, nil, tool.NewRegistry(), catalogFn)
122 frontend := runtime.NewFrontend(nil, nil)
123
124 type page struct {
125 Capabilities []struct {
126 ID string `json:"id"`
127 } `json:"capabilities"`
128 CatalogVersion string `json:"catalog_version"`
129 NextCursor string `json:"next_cursor"`
130 Truncated bool `json:"truncated"`
131 }
132 read := func(raw string) page {
133 t.Helper()
134 out, err := frontend.Execute(context.Background(), json.RawMessage(raw))
135 if err != nil {
136 t.Fatal(err)
137 }
138 var got page
139 if err := json.Unmarshal([]byte(out), &got); err != nil {
140 t.Fatal(err)
141 }
142 return got
143 }
144 first := read(`{"action":"list"}`)
145 if len(first.Capabilities) != 50 || !first.Truncated || first.NextCursor == "" || first.CatalogVersion == "" {
146 t.Fatalf("first page = %+v", first)
147 }
148 second := read(fmt.Sprintf(`{"action":"list","cursor":%q,"limit":50}`, first.NextCursor))
149 if len(second.Capabilities) != 50 || second.CatalogVersion != first.CatalogVersion || second.NextCursor == "" {
150 t.Fatalf("second page = %+v", second)
151 }
152 third := read(fmt.Sprintf(`{"action":"list","cursor":%q,"limit":50}`, second.NextCursor))
153 if len(third.Capabilities) != 23 || third.Truncated || third.NextCursor != "" || third.CatalogVersion != first.CatalogVersion {
154 t.Fatalf("third page = %+v", third)
155 }
156
157 revision++
158 if _, err := frontend.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"action":"list","cursor":%q}`, first.NextCursor))); err == nil || !strings.Contains(err.Error(), "cursor expired") {
159 t.Fatalf("old cursor survived catalog replacement: %v", err)
160 }
161 }
162
163 func TestUseCapabilityListLimitAlsoBoundsServerSummaries(t *testing.T) {
164 specs := make([]plugin.Spec, 120)
165 entries := make([]config.PluginEntry, 120)
166 for i := range specs {
167 name := fmt.Sprintf("server-%03d", i)
168 specs[i] = plugin.Spec{Name: name, Type: "stdio", Command: "unused", Authorized: true}
169 entries[i] = config.PluginEntry{Name: name, Type: "stdio", Command: "unused"}
170 }
171 runtime := NewMCPCapabilityRuntime(context.Background(), nil, specs, tool.NewRegistry(), func() capability.Catalog {
172 return capability.BuildCatalog(capability.CatalogOptions{Plugins: entries})
173 })
174 runtime.ConfigureServers(entries, specs, nil)
175 frontend := runtime.NewFrontend(nil, nil)
176
177 var first struct {
178 Servers []listServerInfo `json:"servers"`
179 NextCursor string `json:"next_cursor"`
180 Truncated bool `json:"truncated"`
181 }
182 out, err := frontend.Execute(context.Background(), json.RawMessage(`{"action":"list","limit":50}`))
183 if err != nil {
184 t.Fatal(err)
185 }
186 if err := json.Unmarshal([]byte(out), &first); err != nil {
187 t.Fatal(err)
188 }
189 if len(first.Servers) != 50 || !first.Truncated || first.NextCursor == "" {
190 t.Fatalf("first server page = %+v", first)
191 }
192 var second struct {
193 Servers []listServerInfo `json:"servers"`
194 }
195 out, err = frontend.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"action":"list","limit":50,"cursor":%q}`, first.NextCursor)))
196 if err != nil {
197 t.Fatal(err)
198 }
199 if err := json.Unmarshal([]byte(out), &second); err != nil {
200 t.Fatal(err)
201 }
202 if len(second.Servers) != 50 || second.Servers[0].Name != "server-050" {
203 t.Fatalf("second server page = %+v", second.Servers)
204 }
205 }
206
207 func TestUseCapabilitySearchLargeCatalogHonorsSmallLimit(t *testing.T) {
208 skills := make([]skill.Skill, 10_000)
209 for i := range skills {
210 skills[i] = skill.Skill{Name: fmt.Sprintf("catalog-%05d", i), Description: "large catalog candidate", Scope: skill.ScopeProject}
211 }
212 catalogFn := func() capability.Catalog {
213 return capability.BuildCatalog(capability.CatalogOptions{Skills: skills})
214 }
215 runtime := NewMCPCapabilityRuntime(context.Background(), nil, nil, tool.NewRegistry(), catalogFn)
216 frontend := runtime.NewFrontend(nil, nil)
217 out, err := frontend.Execute(context.Background(), json.RawMessage(`{"action":"search","query":"catalog","limit":1}`))
218 if err != nil {
219 t.Fatal(err)
220 }
221 var payload struct {
222 Results []json.RawMessage `json:"results"`
223 }
224 if err := json.Unmarshal([]byte(out), &payload); err != nil || len(payload.Results) != 1 {
225 t.Fatalf("large catalog result count=%d err=%v", len(payload.Results), err)
226 }
227 }
228
228 lines GO