| 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 |