| 1 | package skill |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/tool" |
| 10 | ) |
| 11 | |
| 12 | type builtinTestTool struct { |
| 13 | name string |
| 14 | readOnly bool |
| 15 | } |
| 16 | |
| 17 | func (t builtinTestTool) Name() string { return t.name } |
| 18 | func (t builtinTestTool) Description() string { return t.name } |
| 19 | func (t builtinTestTool) Schema() json.RawMessage { |
| 20 | return json.RawMessage(`{"type":"object"}`) |
| 21 | } |
| 22 | func (t builtinTestTool) Execute(context.Context, json.RawMessage) (string, error) { |
| 23 | return "", nil |
| 24 | } |
| 25 | func (t builtinTestTool) ReadOnly() bool { return t.readOnly } |
| 26 | |
| 27 | // TestBuiltinReviewSkillsDeclareReadOnly pins the tool-boundary contract behind |
| 28 | // the review/security-review "Read-only" promise: runners select the read-only |
| 29 | // subagent registry from this flag, so losing it silently re-opens writer bash. |
| 30 | func TestBuiltinReviewSkillsDeclareReadOnly(t *testing.T) { |
| 31 | want := map[string]bool{ |
| 32 | "explore": false, |
| 33 | "research": false, |
| 34 | "review": true, |
| 35 | "security-review": true, |
| 36 | } |
| 37 | for _, sk := range builtinSkills() { |
| 38 | expected, tracked := want[sk.Name] |
| 39 | if !tracked { |
| 40 | continue |
| 41 | } |
| 42 | if sk.ReadOnly != expected { |
| 43 | t.Errorf("builtin %q ReadOnly = %v, want %v", sk.Name, sk.ReadOnly, expected) |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func TestCodeGraphReadToolsRequireKnownNameAndReadOnly(t *testing.T) { |
| 49 | reg := tool.NewRegistry() |
| 50 | reg.Add(builtinTestTool{name: "mcp__codegraph__symbols", readOnly: true}) |
| 51 | reg.Add(builtinTestTool{name: "codegraph_search", readOnly: true}) |
| 52 | reg.Add(builtinTestTool{name: "mcp__codegraph__write_index", readOnly: false}) |
| 53 | reg.Add(builtinTestTool{name: "mcp__other__codegraph_search", readOnly: true}) |
| 54 | |
| 55 | got := CodeGraphReadTools(reg) |
| 56 | want := []string{"codegraph_search", "mcp__codegraph__symbols"} |
| 57 | if len(got) != len(want) { |
| 58 | t.Fatalf("CodeGraphReadTools = %v, want %v", got, want) |
| 59 | } |
| 60 | for i := range want { |
| 61 | if got[i] != want[i] { |
| 62 | t.Fatalf("CodeGraphReadTools = %v, want %v", got, want) |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestBuiltinSkillsIncludeCodeGraphHintAndToolsWhenDiscovered(t *testing.T) { |
| 68 | reg := tool.NewRegistry() |
| 69 | reg.Add(builtinTestTool{name: "mcp__codegraph__symbols", readOnly: true}) |
| 70 | |
| 71 | var explore Skill |
| 72 | for _, sk := range builtinSkills() { |
| 73 | if sk.Name == "explore" { |
| 74 | explore = sk |
| 75 | break |
| 76 | } |
| 77 | } |
| 78 | if explore.Name == "" { |
| 79 | t.Fatal("explore skill not found") |
| 80 | } |
| 81 | if strings.Contains(explore.Body, "Optional installed code graph MCP tools") { |
| 82 | t.Fatalf("base explore body should not include session-specific codegraph hint:\n%s", explore.Body) |
| 83 | } |
| 84 | for _, name := range explore.AllowedTools { |
| 85 | if name == "mcp__codegraph__symbols" { |
| 86 | t.Fatalf("base explore allowed tools = %v, should not include session-specific codegraph tool", explore.AllowedTools) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | explore = WithCodeGraphTools(explore, CodeGraphReadTools(reg)) |
| 91 | if !strings.Contains(explore.Body, "Optional installed code graph MCP tools") { |
| 92 | t.Fatalf("explore body missing optional codegraph hint:\n%s", explore.Body) |
| 93 | } |
| 94 | for _, want := range []string{ |
| 95 | "use LSP for language semantics", |
| 96 | "use code graph tools first for call graph, impact analysis, and architecture relationships", |
| 97 | "use code_index only as the built-in outline/definition-candidate fallback", |
| 98 | } { |
| 99 | if !strings.Contains(explore.Body, want) { |
| 100 | t.Fatalf("explore body missing priority hint %q:\n%s", want, explore.Body) |
| 101 | } |
| 102 | } |
| 103 | found := false |
| 104 | for _, name := range explore.AllowedTools { |
| 105 | if name == "mcp__codegraph__symbols" { |
| 106 | found = true |
| 107 | break |
| 108 | } |
| 109 | } |
| 110 | if !found { |
| 111 | t.Fatalf("explore allowed tools = %v, want codegraph tool", explore.AllowedTools) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestWithCodeGraphToolsOnlyTouchesCodeReadingBuiltins(t *testing.T) { |
| 116 | initSkill := Skill{Name: "init", Scope: ScopeBuiltin, Body: "body", AllowedTools: []string{"read_file"}} |
| 117 | got := WithCodeGraphTools(initSkill, []string{"mcp__codegraph__symbols"}) |
| 118 | if strings.Contains(got.Body, "Optional installed code graph MCP tools") { |
| 119 | t.Fatalf("init skill should not receive codegraph hint:\n%s", got.Body) |
| 120 | } |
| 121 | if len(got.AllowedTools) != 1 || got.AllowedTools[0] != "read_file" { |
| 122 | t.Fatalf("init allowed tools = %v, want unchanged", got.AllowedTools) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestWithCodeGraphToolsSkipsUserSkillOverrides(t *testing.T) { |
| 127 | sk := Skill{Name: "explore", Scope: ScopeProject, Body: "user body", AllowedTools: []string{"read_file"}} |
| 128 | got := WithCodeGraphTools(sk, []string{"mcp__codegraph__symbols"}) |
| 129 | if strings.Contains(got.Body, "Optional installed code graph MCP tools") { |
| 130 | t.Fatalf("project skill override should not receive codegraph hint:\n%s", got.Body) |
| 131 | } |
| 132 | if len(got.AllowedTools) != 1 || got.AllowedTools[0] != "read_file" { |
| 133 | t.Fatalf("project skill override allowed tools = %v, want unchanged", got.AllowedTools) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestWithCodeGraphToolsIsIdempotent(t *testing.T) { |
| 138 | sk := Skill{Name: "explore", Scope: ScopeBuiltin, Body: "body", AllowedTools: []string{"read_file"}} |
| 139 | sk = WithCodeGraphTools(sk, []string{"mcp__codegraph__symbols"}) |
| 140 | sk = WithCodeGraphTools(sk, []string{"mcp__codegraph__symbols"}) |
| 141 | if got := strings.Count(sk.Body, optionalCodeGraphHint); got != 1 { |
| 142 | t.Fatalf("codegraph hint count = %d, want 1; body:\n%s", got, sk.Body) |
| 143 | } |
| 144 | count := 0 |
| 145 | for _, name := range sk.AllowedTools { |
| 146 | if name == "mcp__codegraph__symbols" { |
| 147 | count++ |
| 148 | } |
| 149 | } |
| 150 | if count != 1 { |
| 151 | t.Fatalf("codegraph tool count = %d, want 1; allowed=%v", count, sk.AllowedTools) |
| 152 | } |
| 153 | } |
| 154 |