返回 DeepSeek-Reasonix
usecapability_search_test.go
根目录 / internal / agent / usecapability_search_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "reasonix/internal/capability"
13 "reasonix/internal/plugin"
14 "reasonix/internal/skill"
15 "reasonix/internal/tool"
16 )
17
18 func TestSearchCapabilitiesRanksDeterministicallyAndStaysLocal(t *testing.T) {
19 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
20 spec := plugin.Spec{Name: "teamcity", Type: "stdio", Command: "tc", Authorized: true}
21 cached := make([]plugin.CachedTool, 100)
22 var schemaBytes int
23 for i := range cached {
24 schema := json.RawMessage(fmt.Sprintf(`{"type":"object","properties":{"q%03d":{"type":"string","description":"query field %03d %s"}}}`, i, i, strings.Repeat("x", 800)))
25 schemaBytes += len(schema)
26 cached[i] = plugin.CachedTool{
27 Name: fmt.Sprintf("tool_%03d", i),
28 Description: fmt.Sprintf("TeamCity catalog entry %03d", i),
29 Schema: schema,
30 ReadOnly: true,
31 }
32 }
33 if schemaBytes < 80<<10 {
34 t.Fatalf("fixture schema bytes = %d, want ~88KB class", schemaBytes)
35 }
36 if err := plugin.SaveCachedSchema(spec.Name, plugin.CachedSchema{CacheKey: plugin.SchemaCacheKey(spec), Tools: cached}); err != nil {
37 t.Fatal(err)
38 }
39 plugin.ResetProtocolMetricsForTest()
40 host := plugin.NewHost()
41 defer host.Close()
42 reg := tool.NewRegistry()
43 catalog := func() capability.Catalog {
44 entries := make([]capability.Entry, 0, len(cached)+1)
45 entries = append(entries, capability.Entry{ID: "mcp-server:teamcity", Kind: capability.KindMCPServer, Name: "teamcity", Source: "teamcity", ConnectName: "teamcity"})
46 for _, ct := range cached {
47 entries = append(entries, capability.Entry{
48 ID: "mcp-tool:teamcity/" + ct.Name, Kind: capability.KindMCPTool, Name: ct.Name,
49 Source: "teamcity", Description: ct.Description, ReadOnly: true,
50 })
51 }
52 return capability.Catalog{Entries: entries}
53 }
54 runtime := NewMCPCapabilityRuntime(context.Background(), host, []plugin.Spec{spec}, reg, catalog)
55 proxy := runtime.NewFrontend(nil, nil)
56
57 out, err := proxy.Execute(context.Background(), json.RawMessage(`{"action":"search","query":"tool_007","limit":5}`))
58 if err != nil {
59 t.Fatal(err)
60 }
61 if plugin.ToolsListCount() != 0 {
62 t.Fatalf("search issued remote tools/list")
63 }
64 var payload struct {
65 Results []struct {
66 CapabilityID string `json:"capability_id"`
67 } `json:"results"`
68 }
69 if err := json.Unmarshal([]byte(out), &payload); err != nil {
70 t.Fatalf("decode search: %v\n%s", err, out)
71 }
72 if len(payload.Results) == 0 || len(payload.Results) > 5 {
73 t.Fatalf("results = %d, want 1..5", len(payload.Results))
74 }
75 if payload.Results[0].CapabilityID != "mcp-tool:teamcity/tool_007" {
76 t.Fatalf("top result = %s", payload.Results[0].CapabilityID)
77 }
78
79 inspect, err := proxy.Execute(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"mcp-server:teamcity"}`))
80 if err != nil {
81 t.Fatal(err)
82 }
83 if strings.Contains(inspect, `"input_schema"`) && strings.Count(inspect, "query field") > 3 {
84 t.Fatalf("server inspect included full schemas:\n%s", inspect)
85 }
86 if plugin.ToolsListCount() != 0 {
87 t.Fatalf("inspect issued remote tools/list")
88 }
89 }
90
91 func TestNewSessionSearchDoesNotRelistSharedHost(t *testing.T) {
92 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
93 var calls atomic.Int32
94 server := readonlyMCPServer(t, "shared", &calls)
95 defer server.Close()
96 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
97 defer cancel()
98 host := plugin.NewHost()
99 defer host.Close()
100 spec := plugin.Spec{Name: "shared", Type: "http", URL: server.URL, Authorized: true}
101 plugin.ResetProtocolMetricsForTest()
102 if _, err := host.Add(ctx, spec); err != nil {
103 t.Fatalf("connect: %v", err)
104 }
105 listed := plugin.ToolsListCount()
106 if listed == 0 {
107 t.Fatal("expected initial tools/list")
108 }
109 runtime := NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), func() capability.Catalog {
110 return capability.Catalog{Entries: []capability.Entry{{
111 ID: "mcp-server:shared", Kind: capability.KindMCPServer, Name: "shared", Source: "shared", ConnectName: "shared",
112 }, {
113 ID: "mcp-tool:shared/search", Kind: capability.KindMCPTool, Name: "search", Source: "shared", ReadOnly: true,
114 }}}
115 })
116 first := runtime.NewFrontend(nil, nil)
117 second := runtime.NewFrontend(nil, nil)
118 if _, err := first.Execute(ctx, json.RawMessage(`{"action":"search","query":"search"}`)); err != nil {
119 t.Fatalf("first session search: %v", err)
120 }
121 if _, err := second.Execute(ctx, json.RawMessage(`{"action":"inspect","capability_id":"mcp-server:shared"}`)); err != nil {
122 t.Fatalf("second session inspect: %v", err)
123 }
124 if plugin.ToolsListCount() != listed {
125 t.Fatalf("new session re-listed tools: before=%d after=%d", listed, plugin.ToolsListCount())
126 }
127 }
128
129 func TestInspectSkillReturnsNestedArgumentsContract(t *testing.T) {
130 store := skillStoreWithArchitect(t)
131 reg := tool.NewRegistry()
132 reg.Add(mustSkillRunTool(t, store))
133 proxy := NewUseCapabilityTool(context.Background(), nil, nil, reg, nil, nil, func() capability.Catalog {
134 return capability.Catalog{Entries: []capability.Entry{{
135 ID: "skill:team-architect", Kind: capability.KindSkill, Name: "team-architect",
136 }}}
137 })
138 out, err := proxy.Execute(context.Background(), json.RawMessage(`{"action":"inspect","capability_id":"skill:team-architect"}`))
139 if err != nil {
140 t.Fatal(err)
141 }
142 if !strings.Contains(out, `"arguments"`) || !strings.Contains(out, `"call_example"`) {
143 t.Fatalf("inspect missing nested contract:\n%s", out)
144 }
145 }
146
147 func skillStoreWithArchitect(t *testing.T) *skill.Store {
148 t.Helper()
149 store := skill.New(skill.Options{HomeDir: t.TempDir(), DisableBuiltins: true})
150 content := skill.RenderSkillFile(skill.SkillFileOptions{
151 Name: "team-architect", Description: "architecture review", Body: "review architecture",
152 RunAs: skill.RunSubagent,
153 })
154 if _, err := store.CreateWithContent("team-architect", skill.ScopeGlobal, content); err != nil {
155 t.Fatal(err)
156 }
157 return store
158 }
159
160 func mustSkillRunTool(t *testing.T, store *skill.Store) tool.Tool {
161 t.Helper()
162 return skill.NewRunSkillTool(store, func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) {
163 t.Fatal("subagent must not start during inspect/validation")
164 return "", nil
165 })
166 }
167
167 lines GO