返回 DeepSeek-Reasonix
tools_test.go
根目录 / internal / skill / tools_test.go
1 package skill
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/event"
14 "reasonix/internal/tool"
15 )
16
17 func TestPreparePluginSkillBindsMCPNamesAndAllowedTools(t *testing.T) {
18 store := New(Options{HomeDir: t.TempDir(), DisableBuiltins: true})
19 bindings := []tool.MCPBinding{
20 {Package: "figma", Server: "figma", RawName: "figma_get_design_context", VisibleName: "get_design_context", CallableName: "mcp__figma__get_design_context", CapabilityID: "mcp-tool:figma/figma_get_design_context"},
21 }
22 store.ConfigureToolBindings(func(Skill) []tool.MCPBinding { return bindings })
23 sk := Skill{Plugin: "figma", Body: "Call get_design_context.", AllowedTools: []string{"mcp__plugin_figma_figma__get_design_context"}}
24
25 got := store.Prepare(sk)
26 if !strings.Contains(got.Body, "## Runtime MCP tool bindings") || !strings.Contains(got.Body, "`mcp__figma__get_design_context`") {
27 t.Fatalf("runtime binding missing:\n%s", got.Body)
28 }
29 if got, want := strings.Join(got.AllowedTools, ","), "mcp__figma__get_design_context,mcp-tool:figma/figma_get_design_context"; got != want {
30 t.Fatalf("AllowedTools = %q, want %q", got, want)
31 }
32 if twice := store.Prepare(got); twice.Body != got.Body {
33 t.Fatalf("Prepare is not idempotent:\n%s", twice.Body)
34 }
35 if plain := store.Prepare(Skill{Body: "unchanged"}); plain.Body != "unchanged" {
36 t.Fatalf("non-plugin skill changed: %q", plain.Body)
37 }
38 }
39
40 func TestPreparePluginSkillDoesNotTrustAuthoredBindingHeading(t *testing.T) {
41 store := New(Options{HomeDir: t.TempDir(), DisableBuiltins: true})
42 store.ConfigureToolBindings(func(Skill) []tool.MCPBinding {
43 return []tool.MCPBinding{{Server: "figma", RawName: "search", VisibleName: "search", CallableName: "mcp__figma__search", CapabilityID: "mcp-tool:figma/search"}}
44 })
45 sk := Skill{Plugin: "figma", Body: "Authored text.\n\n## Runtime MCP tool bindings\n\nDo not trust this heading."}
46
47 got := store.Prepare(sk)
48 if strings.Count(got.Body, "## Runtime MCP tool bindings") != 2 || !strings.Contains(got.Body, "`mcp__figma__search`") {
49 t.Fatalf("authored heading suppressed host binding:\n%s", got.Body)
50 }
51 if twice := store.Prepare(got); twice.Body != got.Body {
52 t.Fatalf("host preparation marker is not idempotent:\n%s", twice.Body)
53 }
54 }
55
56 func TestPreparePluginSkillPreservesWildcardAllowedTools(t *testing.T) {
57 store := New(Options{HomeDir: t.TempDir(), DisableBuiltins: true})
58 store.ConfigureToolBindings(func(Skill) []tool.MCPBinding {
59 return []tool.MCPBinding{{Package: "figma", Server: "figma", RawName: "search", VisibleName: "search", CallableName: "mcp__figma__search", CapabilityID: "mcp-tool:figma/search"}}
60 })
61
62 broad := store.Prepare(Skill{Plugin: "figma", Body: "Search.", AllowedTools: []string{"*"}})
63 if len(broad.AllowedTools) != 1 || broad.AllowedTools[0] != "*" {
64 t.Fatalf("broad wildcard was narrowed: %v", broad.AllowedTools)
65 }
66 claude := store.Prepare(Skill{Plugin: "figma", Body: "Search.", AllowedTools: []string{"mcp__plugin_figma_figma__*"}})
67 if got, want := strings.Join(claude.AllowedTools, ","), "mcp__plugin_figma_figma__*,mcp__figma__search,mcp-tool:figma/search"; got != want {
68 t.Fatalf("Claude wildcard mapping = %q, want %q", got, want)
69 }
70 }
71
72 func TestPreparePluginSkillDoesNotWidenAmbiguousAllowedTool(t *testing.T) {
73 store := New(Options{HomeDir: t.TempDir(), DisableBuiltins: true})
74 store.ConfigureToolBindings(func(Skill) []tool.MCPBinding {
75 return []tool.MCPBinding{
76 {Server: "one", RawName: "search", VisibleName: "search", CallableName: "mcp__one__search", CapabilityID: "mcp-tool:one/search"},
77 {Server: "two", RawName: "search", VisibleName: "search", CallableName: "mcp__two__search", CapabilityID: "mcp-tool:two/search"},
78 }
79 })
80 got := store.Prepare(Skill{Plugin: "pkg", Body: "Search.", AllowedTools: []string{"search"}})
81 if len(got.AllowedTools) != 1 || got.AllowedTools[0] != "search" {
82 t.Fatalf("ambiguous literal widened permissions: %v", got.AllowedTools)
83 }
84 }
85
86 func TestRunSkillInline(t *testing.T) {
87 home := t.TempDir()
88 writeSkill(t, home, ".reasonix/skills/note.md", "---\ndescription: take a note\n---\nDo the thing.")
89 tl := NewRunSkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), nil)
90
91 out, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"note","arguments":"with args"}`))
92 if err != nil {
93 t.Fatalf("execute: %v", err)
94 }
95 if !strings.HasPrefix(out, "<skill-pin name=\"note\">") || !strings.HasSuffix(out, "</skill-pin>") {
96 t.Errorf("inline skill should be skill-pin wrapped:\n%s", out)
97 }
98 if !strings.Contains(out, "Do the thing.") || !strings.Contains(out, "Arguments: with args") {
99 t.Errorf("body/args missing:\n%s", out)
100 }
101 }
102
103 func TestRunSkillUnknown(t *testing.T) {
104 tl := NewRunSkillTool(New(Options{HomeDir: t.TempDir(), DisableBuiltins: true}), nil)
105 if _, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"nope"}`)); err == nil {
106 t.Error("unknown skill should error")
107 }
108 }
109
110 func TestRunSkillEnforcesRuntimeProfile(t *testing.T) {
111 home := t.TempDir()
112 writeSkill(t, home, ".reasonix/skills/delivery-only.md", "---\ndescription: ship it\nprofiles: delivery\n---\nDeliver it.")
113 store := New(Options{HomeDir: home, DisableBuiltins: true})
114 store.ConfigureInvocationPolicy("economy", nil)
115 tl := NewRunSkillTool(store, nil)
116
117 _, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"delivery-only"}`))
118 if err == nil || !errors.Is(err, ErrInvocationUnavailable) || !strings.Contains(err.Error(), "unavailable in the economy profile") {
119 t.Fatalf("profile-restricted run_skill error = %v", err)
120 }
121 }
122
123 func TestRunSkillEnforcesRequiredCapabilities(t *testing.T) {
124 home := t.TempDir()
125 writeSkill(t, home, ".reasonix/skills/github-review.md", "---\ndescription: review github\nrequires: mcp-server:github, mcp-tool:github/search_issues\n---\nReview it.")
126 store := New(Options{HomeDir: home, DisableBuiltins: true})
127 store.ConfigureInvocationPolicy("delivery", func(requires []string) []string {
128 return []string{"mcp-tool:github/search_issues"}
129 })
130 tl := NewRunSkillTool(store, nil)
131
132 _, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"github-review"}`))
133 if err == nil || !errors.Is(err, ErrInvocationUnavailable) || !strings.Contains(err.Error(), "requires unavailable capabilities: mcp-tool:github/search_issues") {
134 t.Fatalf("requires-gated run_skill error = %v", err)
135 }
136 }
137
138 func TestRunSkillSubagentNeedsRunner(t *testing.T) {
139 home := t.TempDir()
140 writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody")
141 tl := NewRunSkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), nil) // nil runner
142 if _, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"dig","arguments":"go"}`)); err == nil {
143 t.Error("subagent skill with no runner should error, not silently inline")
144 }
145 }
146
147 func TestRunSkillSubagentRuns(t *testing.T) {
148 home := t.TempDir()
149 writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody")
150 var gotTask string
151 runner := func(_ context.Context, sk Skill, task string, _ SubagentRunOptions) (string, error) {
152 gotTask = task
153 return "answer from " + sk.Name, nil
154 }
155 tl := NewRunSkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), runner)
156 out, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"dig","arguments":"find X"}`))
157 if err != nil {
158 t.Fatalf("execute: %v", err)
159 }
160 if gotTask != "find X" {
161 t.Errorf("runner got task %q", gotTask)
162 }
163 if out != "answer from dig" {
164 t.Errorf("runner output not returned: %q", out)
165 }
166 }
167
168 func TestRunSkillSubagentResultWarnsOnHostDecisionLanguage(t *testing.T) {
169 home := t.TempDir()
170 writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody")
171 runner := func(_ context.Context, sk Skill, task string, _ SubagentRunOptions) (string, error) {
172 return "等待用户批准后再执行 " + sk.Name + " " + task, nil
173 }
174 tl := NewRunSkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), runner)
175 out, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"dig","arguments":"find X"}`))
176 if err != nil {
177 t.Fatalf("execute: %v", err)
178 }
179 if !strings.Contains(out, "Subagent boundary") {
180 t.Fatalf("subagent skill output missing boundary warning:\n%s", out)
181 }
182 }
183
184 func TestRunSkillSubagentCancellationReachesRunner(t *testing.T) {
185 home := t.TempDir()
186 writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody")
187 runner := func(ctx context.Context, _ Skill, _ string, _ SubagentRunOptions) (string, error) {
188 <-ctx.Done()
189 return "", ctx.Err()
190 }
191 tl := NewRunSkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), runner)
192
193 ctx, cancel := context.WithCancel(context.Background())
194 done := make(chan error, 1)
195 go func() {
196 _, err := tl.Execute(ctx, json.RawMessage(`{"name":"dig","arguments":"find X"}`))
197 done <- err
198 }()
199
200 time.Sleep(50 * time.Millisecond)
201 cancel()
202
203 select {
204 case err := <-done:
205 if !errors.Is(err, context.Canceled) {
206 t.Fatalf("Execute error = %v, want context cancellation", err)
207 }
208 case <-time.After(500 * time.Millisecond):
209 t.Fatal("run_skill subagent runner did not observe cancellation promptly")
210 }
211 }
212
213 func TestReadOnlySkillInlineAndIsReadOnly(t *testing.T) {
214 home := t.TempDir()
215 writeSkill(t, home, ".reasonix/skills/note.md", "---\ndescription: take a note\n---\nDo the thing.")
216 tl := NewReadOnlySkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), nil)
217
218 if !tl.ReadOnly() {
219 t.Fatal("read_only_skill must report ReadOnly for permission and restricted-runner classification")
220 }
221 out, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"note","arguments":"with args"}`))
222 if err != nil {
223 t.Fatalf("execute: %v", err)
224 }
225 if !strings.Contains(out, "Do the thing.") || !strings.Contains(out, "Arguments: with args") {
226 t.Errorf("inline body/args missing:\n%s", out)
227 }
228 }
229
230 func TestReadOnlySkillSubagentRunsWithoutContinuation(t *testing.T) {
231 home := t.TempDir()
232 writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody")
233 var gotTask string
234 var gotOpts SubagentRunOptions
235 runner := func(_ context.Context, sk Skill, task string, opts SubagentRunOptions) (string, error) {
236 gotTask = task
237 gotOpts = opts
238 return "read-only answer from " + sk.Name, nil
239 }
240 tl := NewReadOnlySkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), runner)
241 out, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"dig","arguments":"find X"}`))
242 if err != nil {
243 t.Fatalf("execute: %v", err)
244 }
245 if gotTask != "find X" {
246 t.Errorf("runner got task %q", gotTask)
247 }
248 if gotOpts.ContinueFrom != "" || gotOpts.ForkFrom != "" {
249 t.Fatalf("read_only_skill should not pass continuation opts, got %+v", gotOpts)
250 }
251 if out != "read-only answer from dig" {
252 t.Errorf("runner output not returned: %q", out)
253 }
254 }
255
256 func TestReadOnlySkillSubagentRequiresArgs(t *testing.T) {
257 home := t.TempDir()
258 writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody")
259 runner := func(_ context.Context, _ Skill, _ string, _ SubagentRunOptions) (string, error) {
260 return "x", nil
261 }
262 tl := NewReadOnlySkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), runner)
263 if _, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"dig"}`)); err == nil {
264 t.Error("read_only_skill subagent should require arguments")
265 }
266 }
267
268 func TestReadOnlySkillSubagentResolvesProfile(t *testing.T) {
269 home := t.TempDir()
270 writeSkill(t, home, ".reasonix/skills/deep.md", "---\ndescription: deep\nrunAs: subagent\nmodel: deepseek-pro\neffort: max\n---\nbody")
271 tl := NewReadOnlySkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), nil)
272
273 pr, ok := tl.(interface {
274 ResolveProfile(json.RawMessage) *event.Profile
275 })
276 if !ok {
277 t.Fatal("read_only_skill should expose ResolveProfile")
278 }
279 got := pr.ResolveProfile(json.RawMessage(`{"name":"deep","arguments":"x"}`))
280 if got == nil || got.Model != "deepseek-pro" || got.Effort != "max" {
281 t.Fatalf("profile = %+v, want deepseek-pro/max", got)
282 }
283 }
284
285 func TestRunSkillSubagentResolvesProfile(t *testing.T) {
286 home := t.TempDir()
287 writeSkill(t, home, ".reasonix/skills/deep.md", "---\ndescription: deep\nrunAs: subagent\nmodel: deepseek-pro\neffort: max\n---\nbody")
288 tl := NewRunSkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), nil)
289
290 pr, ok := tl.(interface {
291 ResolveProfile(json.RawMessage) *event.Profile
292 })
293 if !ok {
294 t.Fatal("run_skill should expose ResolveProfile")
295 }
296 got := pr.ResolveProfile(json.RawMessage(`{"name":"deep","arguments":"x"}`))
297 if got == nil || got.Model != "deepseek-pro" || got.Effort != "max" {
298 t.Fatalf("profile = %+v, want deepseek-pro/max", got)
299 }
300 }
301
302 func TestRunSkillSubagentRequiresArgs(t *testing.T) {
303 home := t.TempDir()
304 writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody")
305 runner := func(_ context.Context, _ Skill, _ string, _ SubagentRunOptions) (string, error) {
306 return "x", nil
307 }
308 tl := NewRunSkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), runner)
309 if _, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"dig"}`)); err == nil {
310 t.Error("subagent skill should require arguments")
311 }
312 }
313
314 func TestCleanSkillName(t *testing.T) {
315 cases := map[string]string{
316 "explore": "explore",
317 "explore [🧬 subagent]": "explore",
318 "[🧬 subagent] explore": "explore",
319 " review ": "review",
320 "[only a tag]": "",
321 "": "",
322 }
323 for in, want := range cases {
324 if got := cleanSkillName(in); got != want {
325 t.Errorf("cleanSkillName(%q) = %q, want %q", in, got, want)
326 }
327 }
328 }
329
330 func TestBuiltinSubagentToolsRunner(t *testing.T) {
331 var ran string
332 runner := func(_ context.Context, sk Skill, task string, _ SubagentRunOptions) (string, error) {
333 ran = sk.Name + ":" + task
334 return "ok", nil
335 }
336 tools := BuiltinSubagentTools(New(Options{HomeDir: t.TempDir()}), runner)
337 var explore interface {
338 Name() string
339 Execute(context.Context, json.RawMessage) (string, error)
340 }
341 for _, tl := range tools {
342 if tl.Name() == "explore" {
343 explore = tl
344 }
345 }
346 if explore == nil {
347 t.Fatal("explore wrapper tool not built")
348 }
349 if _, err := explore.Execute(context.Background(), json.RawMessage(`{"task":"map the loop"}`)); err != nil {
350 t.Fatalf("execute: %v", err)
351 }
352 if ran != "explore:map the loop" {
353 t.Errorf("runner not invoked correctly: %q", ran)
354 }
355 }
356
357 func TestBuiltinSubagentToolsPassContinuationOptions(t *testing.T) {
358 var got SubagentRunOptions
359 runner := func(_ context.Context, _ Skill, _ string, opts SubagentRunOptions) (string, error) {
360 got = opts
361 return "ok", nil
362 }
363 tools := BuiltinSubagentTools(New(Options{HomeDir: t.TempDir()}), runner)
364 var review interface {
365 Name() string
366 Execute(context.Context, json.RawMessage) (string, error)
367 }
368 for _, tl := range tools {
369 if tl.Name() == "review" {
370 review = tl
371 break
372 }
373 }
374 if review == nil {
375 t.Fatal("review wrapper tool not built")
376 }
377 if _, err := review.Execute(context.Background(), json.RawMessage(`{"task":"again","continue_from":"sa_prev"}`)); err != nil {
378 t.Fatalf("execute: %v", err)
379 }
380 if got.ContinueFrom != "sa_prev" {
381 t.Fatalf("continuation opts = %+v, want continue_from sa_prev", got)
382 }
383 }
384
385 func TestRunSkillToolPassesLegacyForkOption(t *testing.T) {
386 var got SubagentRunOptions
387 runner := func(_ context.Context, _ Skill, _ string, opts SubagentRunOptions) (string, error) {
388 got = opts
389 return "ok", nil
390 }
391 runSkill := NewRunSkillTool(New(Options{HomeDir: t.TempDir()}), runner)
392 if _, err := runSkill.Execute(context.Background(), json.RawMessage(`{"name":"review","arguments":"again","fork_from":"sa_prev"}`)); err != nil {
393 t.Fatalf("execute: %v", err)
394 }
395 if got.ForkFrom != "sa_prev" {
396 t.Fatalf("continuation opts = %+v, want fork_from sa_prev", got)
397 }
398 }
399
400 func TestBuiltinSubagentToolsPassLegacyForkOption(t *testing.T) {
401 var got SubagentRunOptions
402 runner := func(_ context.Context, _ Skill, _ string, opts SubagentRunOptions) (string, error) {
403 got = opts
404 return "ok", nil
405 }
406 tools := BuiltinSubagentTools(New(Options{HomeDir: t.TempDir()}), runner)
407 var review interface {
408 Name() string
409 Execute(context.Context, json.RawMessage) (string, error)
410 }
411 for _, tl := range tools {
412 if tl.Name() == "review" {
413 review = tl
414 break
415 }
416 }
417 if review == nil {
418 t.Fatal("review wrapper tool not built")
419 }
420 if _, err := review.Execute(context.Background(), json.RawMessage(`{"task":"again","fork_from":"sa_prev"}`)); err != nil {
421 t.Fatalf("execute: %v", err)
422 }
423 if got.ForkFrom != "sa_prev" {
424 t.Fatalf("continuation opts = %+v, want fork_from sa_prev", got)
425 }
426 }
427
428 func TestSubagentSkillSchemasExposeOnlyContinueFromForPersistence(t *testing.T) {
429 runSkill := NewRunSkillTool(New(Options{HomeDir: t.TempDir(), DisableBuiltins: true}), nil)
430 runSchema := string(runSkill.Schema())
431 if !strings.Contains(runSchema, `"continue_from"`) {
432 t.Fatalf("run_skill schema = %s, want continue_from", runSchema)
433 }
434 if strings.Contains(runSchema, "fork_from") {
435 t.Fatalf("run_skill schema = %s, want no fork_from", runSchema)
436 }
437
438 tools := BuiltinSubagentTools(New(Options{HomeDir: t.TempDir()}), nil)
439 for _, tl := range tools {
440 schema := string(tl.Schema())
441 if !strings.Contains(schema, `"continue_from"`) {
442 t.Fatalf("%s schema = %s, want continue_from", tl.Name(), schema)
443 }
444 if strings.Contains(schema, "fork_from") {
445 t.Fatalf("%s schema = %s, want no fork_from", tl.Name(), schema)
446 }
447 }
448 }
449
450 func TestBuiltinSubagentToolResolvesProfile(t *testing.T) {
451 store := New(Options{HomeDir: t.TempDir()})
452 tools := BuiltinSubagentTools(store, nil, func(sk Skill) *event.Profile {
453 return &event.Profile{Model: sk.Name + "-model", Effort: "max"}
454 })
455 var review interface {
456 ResolveProfile(json.RawMessage) *event.Profile
457 }
458 for _, tl := range tools {
459 if tl.Name() == "review" {
460 review = tl.(interface {
461 ResolveProfile(json.RawMessage) *event.Profile
462 })
463 break
464 }
465 }
466 if review == nil {
467 t.Fatal("review tool not found")
468 }
469 got := review.ResolveProfile(json.RawMessage(`{"task":"general"}`))
470 if got == nil || got.Model != "review-model" || got.Effort != "max" {
471 t.Fatalf("profile = %+v, want review-model/max", got)
472 }
473 }
474
475 func TestInstallSkill(t *testing.T) {
476 home := t.TempDir()
477 st := New(Options{HomeDir: home, DisableBuiltins: true})
478 tl := NewInstallSkillTool(st, nil)
479
480 out, err := tl.Execute(context.Background(), json.RawMessage(
481 `{"name":"deploy","description":"ship it","body":"steps","runAs":"subagent","model":"deepseek-pro","effort":"max","allowedTools":["bash","read_file"]}`))
482 if err != nil {
483 t.Fatalf("execute: %v", err)
484 }
485 if !strings.Contains(out, `"ok":true`) {
486 t.Errorf("expected ok result, got %s", out)
487 }
488 var res struct {
489 Path string `json:"path"`
490 }
491 if err := json.Unmarshal([]byte(out), &res); err != nil {
492 t.Fatalf("result JSON: %v", err)
493 }
494 wantPath := filepath.Join(home, ".reasonix", "skills", "deploy", SkillFile)
495 if res.Path != wantPath {
496 t.Fatalf("install_skill should report canonical path %s, got %s", wantPath, res.Path)
497 }
498 if _, err := os.Stat(wantPath); err != nil {
499 t.Fatalf("install_skill should write canonical SKILL.md: %v", err)
500 }
501 if _, err := os.Stat(filepath.Join(home, ".reasonix", "skills", "deploy.md")); !errors.Is(err, os.ErrNotExist) {
502 t.Fatalf("install_skill should not write legacy flat deploy.md, stat err=%v", err)
503 }
504 // Round-trips through the store with the frontmatter we wrote.
505 sk, ok := st.Read("deploy")
506 if !ok {
507 t.Fatal("installed skill not readable")
508 }
509 if sk.RunAs != RunSubagent || sk.Model != "deepseek-pro" || sk.Effort != "max" || len(sk.AllowedTools) != 2 {
510 t.Errorf("frontmatter not round-tripped: runAs=%s model=%q effort=%q tools=%v", sk.RunAs, sk.Model, sk.Effort, sk.AllowedTools)
511 }
512 // Refuses overwrite.
513 if _, err := tl.Execute(context.Background(), json.RawMessage(
514 `{"name":"deploy","description":"again","body":"x"}`)); err == nil {
515 t.Error("install_skill should refuse to overwrite")
516 }
517 // Requires description.
518 if _, err := tl.Execute(context.Background(), json.RawMessage(
519 `{"name":"x","description":"","body":"b"}`)); err == nil {
520 t.Error("install_skill should require a description")
521 }
522 }
523
524 func TestRenderSkillFileEmitsColorAndInvocationWhenSet(t *testing.T) {
525 content := RenderSkillFile(SkillFileOptions{
526 Name: "my-agent",
527 Description: "a private helper",
528 Body: "be helpful",
529 RunAs: RunSubagent,
530 Color: "amber",
531 Invocation: "manual",
532 })
533 for _, want := range []string{"color: amber\n", "invocation: manual\n", "runAs: subagent\n"} {
534 if !strings.Contains(content, want) {
535 t.Errorf("rendered content missing %q:\n%s", want, content)
536 }
537 }
538
539 home := t.TempDir()
540 st := New(Options{HomeDir: home, DisableBuiltins: true})
541 if _, err := st.CreateWithContent("my-agent", ScopeGlobal, content); err != nil {
542 t.Fatalf("CreateWithContent: %v", err)
543 }
544 sk, ok := st.Read("my-agent")
545 if !ok {
546 t.Fatal("skill not readable after CreateWithContent")
547 }
548 if sk.Color != "amber" || sk.Invocation != "manual" {
549 t.Errorf("round-trip mismatch: color=%q invocation=%q", sk.Color, sk.Invocation)
550 }
551 }
552
553 // TestRenderSkillFileEscapesYAMLMetacharacters pins the security contract the
554 // reviewer flagged: free text with YAML metacharacters must round-trip intact.
555 // Before the yaml.v3 renderer, a description like "Review code: focus on
556 // security" produced an unparseable block; frontmatter.Split then returned an
557 // EMPTY map and the loader silently fell back to runAs=inline +
558 // invocation=auto — dissolving both the isolation boundary and the
559 // no-autodiscovery guarantee.
560 func TestRenderSkillFileEscapesYAMLMetacharacters(t *testing.T) {
561 cases := []struct {
562 label string
563 desc string
564 }{
565 {"colon", "Review code: focus on security"},
566 {"hash", "Reviews #security and #perf tags"},
567 {"double-quote", `Says "hello" politely`},
568 {"single-quote", "Don't break on apostrophes"},
569 {"newline", "First line\nsecond line"},
570 {"leading-special", "- starts like a list item"},
571 {"yaml-lookalike", "runAs: inline"},
572 }
573 for _, tc := range cases {
574 t.Run(tc.label, func(t *testing.T) {
575 home := t.TempDir()
576 st := New(Options{HomeDir: home, DisableBuiltins: true})
577 content := RenderSkillFile(SkillFileOptions{
578 Name: "esc",
579 Description: tc.desc,
580 Body: "the body",
581 RunAs: RunSubagent,
582 Invocation: "manual",
583 })
584 if _, err := st.CreateWithContent("esc", ScopeGlobal, content); err != nil {
585 t.Fatalf("CreateWithContent: %v", err)
586 }
587 sk, ok := st.Read("esc")
588 if !ok {
589 t.Fatalf("skill unreadable; rendered content:\n%s", content)
590 }
591 // The load-bearing assertions: the security-relevant fields must
592 // survive, never silently reset to their permissive defaults.
593 if sk.RunAs != RunSubagent {
594 t.Errorf("RunAs = %q, want subagent (isolation lost); content:\n%s", sk.RunAs, content)
595 }
596 if sk.Invocation != "manual" {
597 t.Errorf("Invocation = %q, want manual (autodiscovery re-enabled); content:\n%s", sk.Invocation, content)
598 }
599 wantDesc := strings.TrimSpace(tc.desc)
600 if tc.label == "newline" {
601 // frontmatter.Split returns the scalar as parsed; the multi-line
602 // value survives YAML round-trip intact.
603 wantDesc = "First line\nsecond line"
604 }
605 if sk.Description != wantDesc {
606 t.Errorf("Description = %q, want %q", sk.Description, wantDesc)
607 }
608 if sk.Body != "the body" {
609 t.Errorf("Body = %q, want %q", sk.Body, "the body")
610 }
611 })
612 }
613 }
614
615 func TestRenderSkillFileOmitsColorAndInvocationByDefault(t *testing.T) {
616 content := RenderSkillFile(SkillFileOptions{
617 Name: "plain-inline",
618 Description: "no extras",
619 Body: "body text",
620 RunAs: RunInline,
621 })
622 for _, unwanted := range []string{"color:", "invocation:", "runAs:"} {
623 if strings.Contains(content, unwanted) {
624 t.Errorf("rendered content should omit %q when unset:\n%s", unwanted, content)
625 }
626 }
627 }
628
629 func TestReadSkillLoadsInlineAndIsReadOnly(t *testing.T) {
630 home := t.TempDir()
631 writeSkill(t, home, ".reasonix/skills/note.md", "---\ndescription: take a note\n---\nDo the thing.")
632 tl := NewReadSkillTool(New(Options{HomeDir: home, DisableBuiltins: true}))
633
634 if !tl.ReadOnly() {
635 t.Fatal("read_skill must be ReadOnly so it works in plan mode")
636 }
637 out, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"note","arguments":"with args"}`))
638 if err != nil {
639 t.Fatalf("execute: %v", err)
640 }
641 if !strings.Contains(out, "Do the thing.") || !strings.Contains(out, "Arguments: with args") {
642 t.Errorf("inline body/args missing:\n%s", out)
643 }
644 }
645
646 func TestReadSkillRejectsSubagent(t *testing.T) {
647 home := t.TempDir()
648 writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody")
649 tl := NewReadSkillTool(New(Options{HomeDir: home, DisableBuiltins: true}))
650
651 if _, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"dig"}`)); err == nil || !strings.Contains(err.Error(), "run_skill") {
652 t.Fatalf("read_skill on a subagent skill should point to run_skill, got %v", err)
653 }
654 }
655
655 lines GO