返回 DeepSeek-Reasonix
subagents_app_test.go
根目录 / desktop / subagents_app_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "reasonix/internal/command"
16 "reasonix/internal/config"
17 "reasonix/internal/control"
18 "reasonix/internal/permission"
19 "reasonix/internal/skill"
20 "reasonix/internal/tool"
21 )
22
23 func newTestSubagentApp(t *testing.T) *App {
24 t.Helper()
25 home := t.TempDir()
26 t.Setenv("HOME", home)
27 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
28 t.Setenv("AppData", filepath.Join(home, "AppData"))
29 st := skill.New(skill.Options{HomeDir: home})
30 a := NewApp()
31 a.setTestCtrl(control.New(control.Options{AllSkillStore: st, SkillStore: st}), "")
32 t.Cleanup(func() { a.activeCtrl().Close() })
33 return a
34 }
35
36 func TestCreateSubagentProfileWritesManualInvocationSubagentSkill(t *testing.T) {
37 a := newTestSubagentApp(t)
38 path, err := a.CreateSubagentProfile(SubagentProfileInput{
39 Name: "my-formatter",
40 Description: "formats code the way I like",
41 SystemPrompt: "You are a code formatting assistant.",
42 Color: "amber",
43 AllowedTools: []string{"read_file", "edit_file"},
44 Scope: "global",
45 })
46 if err != nil {
47 t.Fatalf("CreateSubagentProfile: %v", err)
48 }
49 if path == "" {
50 t.Fatal("expected a non-empty path")
51 }
52
53 views := a.SkillsSettings().Skills
54 var found *SkillView
55 for i := range views {
56 if views[i].Name == "my-formatter" {
57 found = &views[i]
58 }
59 }
60 if found == nil {
61 t.Fatalf("created profile missing from SkillsSettings: %+v", views)
62 }
63 if found.RunAs != "subagent" || found.Invocation != "/my-formatter" || found.InvocationMode != "manual" || found.Color != "amber" {
64 t.Fatalf("profile fields wrong: %+v", found)
65 }
66 }
67
68 func TestCreateSubagentProfileRejectsBuiltinNameCollision(t *testing.T) {
69 a := newTestSubagentApp(t)
70 _, err := a.CreateSubagentProfile(SubagentProfileInput{
71 Name: "explore",
72 Description: "shadow the built-in",
73 SystemPrompt: "do something else entirely",
74 })
75 if err == nil {
76 t.Fatal("expected an error naming a built-in subagent")
77 }
78 }
79
80 func TestCreateSubagentProfileRejectsReservedSlashNames(t *testing.T) {
81 for _, name := range []string{"clear", "mcp__server__prompt"} {
82 t.Run(name, func(t *testing.T) {
83 a := newTestSubagentApp(t)
84 _, err := a.CreateSubagentProfile(SubagentProfileInput{Name: name, Description: "d", SystemPrompt: "body"})
85 if err == nil || !strings.Contains(err.Error(), "slash command namespace") {
86 t.Fatalf("CreateSubagentProfile(%q) error = %v", name, err)
87 }
88 })
89 }
90 }
91
92 func TestCreateSubagentProfileRejectsCustomCommandCollision(t *testing.T) {
93 home := t.TempDir()
94 t.Setenv("HOME", home)
95 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
96 st := skill.New(skill.Options{HomeDir: home})
97 a := NewApp()
98 a.setTestCtrl(control.New(control.Options{
99 AllSkillStore: st,
100 SkillStore: st,
101 Commands: []command.Command{{Name: "formatter"}},
102 }), "")
103 t.Cleanup(func() { a.activeCtrl().Close() })
104 _, err := a.CreateSubagentProfile(SubagentProfileInput{Name: "formatter", Description: "d", SystemPrompt: "body"})
105 if err == nil || !strings.Contains(err.Error(), "slash command namespace") {
106 t.Fatalf("custom command collision error = %v", err)
107 }
108 }
109
110 func TestCreateSubagentProfileRejectsDuplicateName(t *testing.T) {
111 a := newTestSubagentApp(t)
112 input := SubagentProfileInput{Name: "dup", Description: "first", SystemPrompt: "body"}
113 if _, err := a.CreateSubagentProfile(input); err != nil {
114 t.Fatalf("first create: %v", err)
115 }
116 if _, err := a.CreateSubagentProfile(input); err == nil {
117 t.Fatal("expected an error creating a duplicate name")
118 }
119 }
120
121 func TestCreateSubagentProfileRequiresDescriptionAndPrompt(t *testing.T) {
122 a := newTestSubagentApp(t)
123 if _, err := a.CreateSubagentProfile(SubagentProfileInput{Name: "x", SystemPrompt: "body"}); err == nil {
124 t.Error("expected an error for a missing description")
125 }
126 if _, err := a.CreateSubagentProfile(SubagentProfileInput{Name: "x", Description: "d"}); err == nil {
127 t.Error("expected an error for a missing system prompt")
128 }
129 }
130
131 func TestCreateSubagentProfileScopeIsStrictButEmptyRemainsGlobal(t *testing.T) {
132 a := newTestSubagentApp(t)
133 path, err := a.CreateSubagentProfile(SubagentProfileInput{
134 Name: "default-global", Description: "d", SystemPrompt: "body",
135 })
136 if err != nil {
137 t.Fatalf("empty scope should preserve the legacy global default: %v", err)
138 }
139 if !strings.Contains(filepath.ToSlash(path), "/.reasonix/skills/") {
140 t.Fatalf("empty scope path = %q, want global Reasonix skills dir", path)
141 }
142 if _, err := a.CreateSubagentProfile(SubagentProfileInput{
143 Name: "bad-scope", Description: "d", SystemPrompt: "body", Scope: "custom",
144 }); err == nil || !strings.Contains(err.Error(), "unsupported") {
145 t.Fatalf("custom create scope error = %v, want explicit rejection", err)
146 }
147 for _, sk := range a.SkillsSettings().Skills {
148 if sk.Name == "bad-scope" {
149 t.Fatal("rejected custom scope must not fall back to a global file")
150 }
151 }
152 }
153
154 func TestUpdateAndDeleteSubagentProfileRejectUnsupportedScopeWithoutTouchingGlobal(t *testing.T) {
155 a := newTestSubagentApp(t)
156 path, err := a.CreateSubagentProfile(SubagentProfileInput{
157 Name: "scope-guard", Description: "original", SystemPrompt: "body", Scope: "global",
158 })
159 if err != nil {
160 t.Fatalf("create: %v", err)
161 }
162 before, err := os.ReadFile(path)
163 if err != nil {
164 t.Fatal(err)
165 }
166 if err := a.UpdateSubagentProfile("scope-guard", "custom", SubagentProfileInput{
167 Description: "changed", SystemPrompt: "changed body",
168 }); err == nil || !strings.Contains(err.Error(), "unsupported") {
169 t.Fatalf("custom update scope error = %v, want explicit rejection", err)
170 }
171 if err := a.DeleteSubagentProfile("scope-guard", "anything-else"); err == nil || !strings.Contains(err.Error(), "unsupported") {
172 t.Fatalf("unknown delete scope error = %v, want explicit rejection", err)
173 }
174 after, err := os.ReadFile(path)
175 if err != nil {
176 t.Fatalf("rejected delete removed the global profile: %v", err)
177 }
178 if string(after) != string(before) {
179 t.Fatalf("rejected custom update modified the global profile:\nbefore=%s\nafter=%s", before, after)
180 }
181 }
182
183 func TestUpdateSubagentProfileOverwritesFields(t *testing.T) {
184 a := newTestSubagentApp(t)
185 if _, err := a.CreateSubagentProfile(SubagentProfileInput{
186 Name: "editable-agent", Description: "v1", SystemPrompt: "old body", Color: "amber", Scope: "global",
187 }); err != nil {
188 t.Fatalf("create: %v", err)
189 }
190 if err := a.UpdateSubagentProfile("editable-agent", "global", SubagentProfileInput{
191 Description: "v2", SystemPrompt: "new body", Color: "blue", Model: "deepseek/deepseek-pro", AllowedTools: []string{"read_file"},
192 }); err != nil {
193 t.Fatalf("UpdateSubagentProfile: %v", err)
194 }
195 var found *SkillView
196 for _, sk := range a.SkillsSettings().Skills {
197 if sk.Name == "editable-agent" {
198 found = &sk
199 }
200 }
201 if found == nil {
202 t.Fatal("editable-agent missing after update")
203 }
204 if found.Description != "v2" || found.Color != "blue" || found.Model != "deepseek/deepseek-pro" || found.Invocation != "/editable-agent" || found.InvocationMode != "manual" || found.RunAs != "subagent" {
205 t.Fatalf("update did not apply as expected: %+v", found)
206 }
207 if len(found.AllowedTools) != 1 || found.AllowedTools[0] != "read_file" {
208 t.Fatalf("AllowedTools not updated: %v", found.AllowedTools)
209 }
210 }
211
212 func TestUpdateSubagentProfileRequiresDescriptionAndPrompt(t *testing.T) {
213 a := newTestSubagentApp(t)
214 if _, err := a.CreateSubagentProfile(SubagentProfileInput{
215 Name: "editable-agent2", Description: "v1", SystemPrompt: "old body", Scope: "global",
216 }); err != nil {
217 t.Fatalf("create: %v", err)
218 }
219 if err := a.UpdateSubagentProfile("editable-agent2", "global", SubagentProfileInput{SystemPrompt: "new body"}); err == nil {
220 t.Error("expected an error for a missing description")
221 }
222 if err := a.UpdateSubagentProfile("editable-agent2", "global", SubagentProfileInput{Description: "d"}); err == nil {
223 t.Error("expected an error for a missing system prompt")
224 }
225 }
226
227 func TestUpdateSubagentProfileRefusesNonManualSkill(t *testing.T) {
228 a := newTestSubagentApp(t)
229 home := os.Getenv("HOME")
230 // A hand-authored subagent skill without invocation: manual — the exact
231 // shape the reviewer flagged: editing it here would silently drop fields.
232 dir := filepath.Join(home, ".reasonix", "skills", "hand-authored")
233 if err := os.MkdirAll(dir, 0o755); err != nil {
234 t.Fatal(err)
235 }
236 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"),
237 []byte("---\ndescription: hand written\nrunAs: subagent\nread-only: true\n---\nbody"), 0o644); err != nil {
238 t.Fatal(err)
239 }
240 err := a.UpdateSubagentProfile("hand-authored", "global", SubagentProfileInput{Description: "x", SystemPrompt: "y"})
241 if err == nil {
242 t.Fatal("expected refusal for a non-manual skill")
243 }
244 if !strings.Contains(err.Error(), "manual") {
245 t.Fatalf("error should explain the manual-invocation rule, got: %v", err)
246 }
247 }
248
249 func TestUpdateSubagentProfileRefusesUnmanagedFrontmatter(t *testing.T) {
250 a := newTestSubagentApp(t)
251 home := os.Getenv("HOME")
252 // invocation: manual but carrying an unmanaged routing key — dropping it
253 // on save would silently change discovery/auto-use semantics.
254 dir := filepath.Join(home, ".reasonix", "skills", "manual-rich")
255 if err := os.MkdirAll(dir, 0o755); err != nil {
256 t.Fatal(err)
257 }
258 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"),
259 []byte("---\ndescription: locked down\nrunAs: subagent\ninvocation: manual\ntriggers: [deploy]\n---\nbody"), 0o644); err != nil {
260 t.Fatal(err)
261 }
262 err := a.UpdateSubagentProfile("manual-rich", "global", SubagentProfileInput{Description: "x", SystemPrompt: "y"})
263 if err == nil {
264 t.Fatal("expected refusal for unmanaged frontmatter keys")
265 }
266 if !strings.Contains(err.Error(), "triggers") {
267 t.Fatalf("error should name the unmanaged key, got: %v", err)
268 }
269 // The file must be untouched by the refused edit.
270 raw, rerr := os.ReadFile(filepath.Join(dir, "SKILL.md"))
271 if rerr != nil || !strings.Contains(string(raw), "triggers:") {
272 t.Fatalf("refused edit must not modify the file, got: %s (%v)", raw, rerr)
273 }
274 }
275
276 func TestUpdateSubagentProfileRoundTripsReadOnly(t *testing.T) {
277 a := newTestSubagentApp(t)
278 path, err := a.CreateSubagentProfile(SubagentProfileInput{
279 Name: "ro-agent", Description: "readonly", SystemPrompt: "stay read only",
280 ReadOnly: true, Scope: "global",
281 })
282 if err != nil {
283 t.Fatal(err)
284 }
285 raw, err := os.ReadFile(path)
286 if err != nil {
287 t.Fatal(err)
288 }
289 if !strings.Contains(string(raw), "read-only: true") {
290 t.Fatalf("create should emit read-only frontmatter, got:\n%s", raw)
291 }
292 if err := a.UpdateSubagentProfile("ro-agent", "global", SubagentProfileInput{
293 Description: "readonly v2", SystemPrompt: "still read only", ReadOnly: true,
294 }); err != nil {
295 t.Fatal(err)
296 }
297 raw, err = os.ReadFile(path)
298 if err != nil {
299 t.Fatal(err)
300 }
301 if !strings.Contains(string(raw), "read-only: true") {
302 t.Fatalf("update must preserve read-only, got:\n%s", raw)
303 }
304 if !strings.Contains(string(raw), "readonly v2") {
305 t.Fatalf("update must change description, got:\n%s", raw)
306 }
307 }
308
309 // TestUpdateSubagentProfileRefusesManualInlineSkill pins the runAs guard: a
310 // hand-authored manual-invocation INLINE skill carries only editor-managed
311 // frontmatter keys, so without an explicit runAs check the update path would
312 // rewrite it with runAs: subagent — silently converting an inline playbook
313 // into an isolated subagent.
314 func TestUpdateSubagentProfileRefusesManualInlineSkill(t *testing.T) {
315 a := newTestSubagentApp(t)
316 home := os.Getenv("HOME")
317 dir := filepath.Join(home, ".reasonix", "skills", "manual-inline")
318 if err := os.MkdirAll(dir, 0o755); err != nil {
319 t.Fatal(err)
320 }
321 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"),
322 []byte("---\ndescription: quiet inline playbook\ninvocation: manual\n---\ninline body"), 0o644); err != nil {
323 t.Fatal(err)
324 }
325 err := a.UpdateSubagentProfile("manual-inline", "global", SubagentProfileInput{Description: "x", SystemPrompt: "y"})
326 if err == nil {
327 t.Fatal("expected refusal for a manual inline skill")
328 }
329 if !strings.Contains(err.Error(), "subagent") {
330 t.Fatalf("error should explain the runAs rule, got: %v", err)
331 }
332 raw, rerr := os.ReadFile(filepath.Join(dir, "SKILL.md"))
333 if rerr != nil || strings.Contains(string(raw), "runAs: subagent") {
334 t.Fatalf("refused edit must not convert the inline skill, got: %s (%v)", raw, rerr)
335 }
336 }
337
338 // TestDeleteSubagentProfileRefusesNonProfileSkill pins the delete guard: the
339 // bridge method must not remove a user skill this page never owned, even when
340 // called directly with a matching name+scope.
341 func TestDeleteSubagentProfileRefusesNonProfileSkill(t *testing.T) {
342 a := newTestSubagentApp(t)
343 home := os.Getenv("HOME")
344 dir := filepath.Join(home, ".reasonix", "skills", "hand-skill")
345 if err := os.MkdirAll(dir, 0o755); err != nil {
346 t.Fatal(err)
347 }
348 file := filepath.Join(dir, "SKILL.md")
349 if err := os.WriteFile(file,
350 []byte("---\ndescription: precious hand-authored playbook\nrunAs: subagent\ntriggers: deploy\n---\nbody"), 0o644); err != nil {
351 t.Fatal(err)
352 }
353 if err := a.DeleteSubagentProfile("hand-skill", "global"); err == nil {
354 t.Fatal("expected refusal deleting a non-profile skill")
355 }
356 if _, err := os.Stat(file); err != nil {
357 t.Fatalf("refused delete must leave the file in place: %v", err)
358 }
359 }
360
361 func TestUpdateSubagentProfileRefusesExpandedReferences(t *testing.T) {
362 a := newTestSubagentApp(t)
363 home := os.Getenv("HOME")
364 dir := filepath.Join(home, ".reasonix", "skills", "with-refs")
365 if err := os.MkdirAll(filepath.Join(dir, "references"), 0o755); err != nil {
366 t.Fatal(err)
367 }
368 if err := os.WriteFile(filepath.Join(dir, "SKILL.md"),
369 []byte("---\ndescription: has refs\nrunAs: subagent\ninvocation: manual\n---\nbody"), 0o644); err != nil {
370 t.Fatal(err)
371 }
372 if err := os.WriteFile(filepath.Join(dir, "references", "extra.md"), []byte("depth material"), 0o644); err != nil {
373 t.Fatal(err)
374 }
375 err := a.UpdateSubagentProfile("with-refs", "global", SubagentProfileInput{Description: "x", SystemPrompt: "y"})
376 if err == nil {
377 t.Fatal("expected refusal for a profile with a references/ dir")
378 }
379 if !strings.Contains(err.Error(), "references") {
380 t.Fatalf("error should name the references dir, got: %v", err)
381 }
382 }
383
384 func TestUpdateSubagentProfileWrongScopeFailsSafely(t *testing.T) {
385 a := newTestSubagentApp(t)
386 if _, err := a.CreateSubagentProfile(SubagentProfileInput{
387 Name: "editable-agent3", Description: "v1", SystemPrompt: "old body", Scope: "global",
388 }); err != nil {
389 t.Fatalf("create: %v", err)
390 }
391 if err := a.UpdateSubagentProfile("editable-agent3", "project", SubagentProfileInput{Description: "v2", SystemPrompt: "new body"}); err == nil {
392 t.Fatal("expected an error updating with the wrong scope")
393 }
394 for _, sk := range a.SkillsSettings().Skills {
395 if sk.Name == "editable-agent3" && sk.Description != "v1" {
396 t.Fatalf("profile should be unchanged after a refused scope-mismatched update, got description=%q", sk.Description)
397 }
398 }
399 }
400
401 func TestTrySubagentRegistryIsReadOnly(t *testing.T) {
402 reg := trySubagentToolRegistry(config.Default(), t.TempDir(), nil)
403 for _, writer := range []string{"write_file", "edit_file", "multi_edit", "move_file", "notebook_edit", "delete_range", "delete_symbol"} {
404 if _, ok := reg.Get(writer); ok {
405 t.Errorf("try registry should strip writer tool %q; got %v", writer, reg.Names())
406 }
407 }
408 for _, meta := range []string{"task", "run_skill", "install_skill", "install_source", "parallel_tasks", "fleet"} {
409 if _, ok := reg.Get(meta); ok {
410 t.Errorf("try registry should strip meta/delegation tool %q; got %v", meta, reg.Names())
411 }
412 }
413 if _, ok := reg.Get("read_file"); !ok {
414 t.Fatalf("try registry should keep read_file; got %v", reg.Names())
415 }
416 }
417
418 func TestTrySubagentRegistryShellEnforcesReadOnlyPolicy(t *testing.T) {
419 reg := trySubagentToolRegistry(config.Default(), t.TempDir(), nil)
420 shell, ok := reg.Get("bash")
421 if !ok {
422 shell, ok = reg.Get("pwsh")
423 }
424 if !ok {
425 t.Fatalf("try registry should keep the native shell; got %v", reg.Names())
426 }
427 if !shell.ReadOnly() {
428 t.Fatal("try shell should report ReadOnly=true (restricted read-only wrapper)")
429 }
430 out, err := shell.Execute(context.Background(), json.RawMessage(`{"command":"rm -rf /tmp/x"}`))
431 msg, blocked := tool.BlockedMessage(err)
432 if low := strings.ToLower(msg); !blocked || (!strings.Contains(low, "plan mode") && !strings.Contains(low, "blocked") && !strings.Contains(low, "not allowed")) {
433 t.Fatalf("write-capable command should be refused by the read-only policy, got %q, %v", out, err)
434 }
435 }
436
437 func TestTrySubagentRegistryHonorsAllowedTools(t *testing.T) {
438 reg := trySubagentToolRegistry(config.Default(), t.TempDir(), []string{"read_file", "grep", "write_file"})
439 if _, ok := reg.Get("read_file"); !ok {
440 t.Fatalf("allowlisted read_file missing; got %v", reg.Names())
441 }
442 if _, ok := reg.Get("write_file"); ok {
443 t.Fatalf("write_file must stay stripped even when allowlisted; got %v", reg.Names())
444 }
445 if _, ok := reg.Get("ls"); ok {
446 t.Fatalf("ls not in the allowlist, should be absent; got %v", reg.Names())
447 }
448 }
449
450 // Try tools resolve relative paths against the active tab, not the process CWD;
451 // otherwise a multi-tab run could send a different project's files upstream.
452 func TestTrySubagentRegistryResolvesRelativePathsAgainstWorkspaceRoot(t *testing.T) {
453 root := t.TempDir()
454 if err := os.WriteFile(filepath.Join(root, "marker.txt"), []byte("workspace-bound"), 0o644); err != nil {
455 t.Fatal(err)
456 }
457 cwd, err := os.Getwd()
458 if err != nil {
459 t.Fatal(err)
460 }
461 if cwd == root {
462 t.Fatal("test requires process CWD != workspace root")
463 }
464
465 reg := trySubagentToolRegistry(config.Default(), root, nil)
466 rf, ok := reg.Get("read_file")
467 if !ok {
468 t.Fatalf("read_file missing; got %v", reg.Names())
469 }
470 out, err := rf.Execute(context.Background(), json.RawMessage(`{"path":"marker.txt"}`))
471 if err != nil {
472 t.Fatalf("relative read against the workspace root failed (resolved against process CWD?): %v", err)
473 }
474 if !strings.Contains(out, "workspace-bound") {
475 t.Fatalf("relative read returned wrong content: %s", out)
476 }
477
478 ls, ok := reg.Get("ls")
479 if !ok {
480 t.Fatalf("ls missing; got %v", reg.Names())
481 }
482 out, err = ls.Execute(context.Background(), json.RawMessage(`{"path":"."}`))
483 if err != nil {
484 t.Fatalf("relative ls against the workspace root failed: %v", err)
485 }
486 if !strings.Contains(out, "marker.txt") {
487 t.Fatalf("ls of workspace root missing marker.txt (listed process CWD instead?): %s", out)
488 }
489 }
490
491 func TestTrySubagentProfileRequiresTaskAndPrompt(t *testing.T) {
492 isolateDesktopUserDirs(t)
493 a := NewApp()
494 if _, err := a.TrySubagentProfile(SubagentProfileInput{SystemPrompt: "be helpful"}, ""); err == nil {
495 t.Error("expected an error for a missing task")
496 }
497 if _, err := a.TrySubagentProfile(SubagentProfileInput{}, "do something"); err == nil {
498 t.Error("expected an error for a missing system prompt")
499 }
500 }
501
502 func TestTrySubagentProfilePermissionGateFailsClosedInReadOnly(t *testing.T) {
503 gate := trySubagentPermissionGate(permission.New("read-only", nil, nil, nil))
504 allow, reason, err := gate.Check(context.Background(), "write_file", json.RawMessage(`{"path":"result.txt"}`), false)
505 if err != nil {
506 t.Fatal(err)
507 }
508 if allow || !strings.Contains(reason, "denied") {
509 t.Fatalf("headless read-only gate = (%v, %q), want fail-closed denial", allow, reason)
510 }
511
512 allow, reason, err = gate.Check(context.Background(), "read_file", json.RawMessage(`{"path":"input.txt"}`), true)
513 if err != nil || !allow || reason != "" {
514 t.Fatalf("read-only call = (%v, %q, %v), want allow", allow, reason, err)
515 }
516 }
517
518 func TestTrySubagentProfileRejectsUnknownModel(t *testing.T) {
519 isolateDesktopUserDirs(t)
520 a := NewApp()
521 _, err := a.TrySubagentProfile(SubagentProfileInput{
522 SystemPrompt: "be helpful",
523 Model: "nope/does-not-exist",
524 }, "do something")
525 if err == nil {
526 t.Error("expected an error for an unresolvable model ref")
527 }
528 }
529
530 func TestTrySubagentPermissionGateFailsClosedOnAsk(t *testing.T) {
531 cfg := config.Default()
532 cfg.Permissions.Ask = []string{"read_file"}
533 policy := permission.New(cfg.Permissions.Mode, cfg.Permissions.Allow, cfg.Permissions.Ask, cfg.Permissions.Deny).
534 WithAllowDynamicBashFallback(cfg.Permissions.AllowDynamicBash)
535 gate := trySubagentPermissionGate(policy)
536
537 allow, reason, err := gate.Check(context.Background(), "read_file", json.RawMessage(`{"path":"README.md"}`), true)
538 if err != nil {
539 t.Fatalf("ask decision returned an error instead of a denial: %v", err)
540 }
541 if allow || (!strings.Contains(strings.ToLower(reason), "denied") &&
542 !strings.Contains(strings.ToLower(reason), "declined")) {
543 t.Fatalf("explicit Ask decision allow=%v reason=%q, want fail-closed denial", allow, reason)
544 }
545
546 cfg.Permissions.Ask = nil
547 policy = permission.New(cfg.Permissions.Mode, cfg.Permissions.Allow, cfg.Permissions.Ask, cfg.Permissions.Deny).
548 WithAllowDynamicBashFallback(cfg.Permissions.AllowDynamicBash)
549 allow, reason, err = trySubagentPermissionGate(policy).Check(context.Background(), "read_file", json.RawMessage(`{"path":"README.md"}`), true)
550 if err != nil || !allow {
551 t.Fatalf("ordinary read-only fallback allow=%v reason=%q err=%v, want allowed", allow, reason, err)
552 }
553 }
554
555 func TestDeleteSubagentProfileRemovesIt(t *testing.T) {
556 a := newTestSubagentApp(t)
557 if _, err := a.CreateSubagentProfile(SubagentProfileInput{
558 Name: "temp-agent", Description: "d", SystemPrompt: "body", Scope: "global",
559 }); err != nil {
560 t.Fatalf("create: %v", err)
561 }
562 if err := a.DeleteSubagentProfile("temp-agent", "global"); err != nil {
563 t.Fatalf("DeleteSubagentProfile: %v", err)
564 }
565 for _, sk := range a.SkillsSettings().Skills {
566 if sk.Name == "temp-agent" {
567 t.Fatal("deleted profile still present")
568 }
569 }
570 }
571
572 func TestSetSubagentProfileModelAndEffortRoundTripPerName(t *testing.T) {
573 isolateDesktopUserDirs(t)
574 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
575 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
576 t.Fatalf("mkdir config dir: %v", err)
577 }
578 if err := os.WriteFile(config.UserConfigPath(), []byte(`
579 default_model = "deepseek/deepseek-v4-flash"
580
581 [[providers]]
582 name = "deepseek"
583 kind = "openai"
584 base_url = "https://api.deepseek.com"
585 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
586 default = "deepseek-v4-flash"
587 api_key_env = "DEEPSEEK_API_KEY"
588 `), 0o644); err != nil {
589 t.Fatalf("write config: %v", err)
590 }
591
592 app := NewApp()
593 if err := app.SetSubagentProfileModel("explore", "deepseek/deepseek-v4-pro"); err != nil {
594 t.Fatalf("SetSubagentProfileModel: %v", err)
595 }
596 if err := app.SetSubagentProfileEffort("explore", "max"); err != nil {
597 t.Fatalf("SetSubagentProfileEffort: %v", err)
598 }
599
600 cfg := config.LoadForEdit(config.UserConfigPath())
601 if cfg.Agent.SubagentModels["explore"] != "deepseek/deepseek-v4-pro" || cfg.Agent.SubagentEfforts["explore"] != "max" {
602 t.Fatalf("saved per-name overrides = model:%q effort:%q", cfg.Agent.SubagentModels["explore"], cfg.Agent.SubagentEfforts["explore"])
603 }
604 // A different skill name must be unaffected — this is a per-name map, not
605 // a global default.
606 if cfg.Agent.SubagentModel != "" || cfg.Agent.SubagentEffort != "" {
607 t.Fatalf("global subagent defaults should be untouched: model:%q effort:%q", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)
608 }
609
610 // Clearing (empty ref/level) removes the map entry rather than storing "".
611 if err := app.SetSubagentProfileModel("explore", ""); err != nil {
612 t.Fatalf("clear SetSubagentProfileModel: %v", err)
613 }
614 if err := app.SetSubagentProfileEffort("explore", ""); err != nil {
615 t.Fatalf("clear SetSubagentProfileEffort: %v", err)
616 }
617 cfg = config.LoadForEdit(config.UserConfigPath())
618 if _, ok := cfg.Agent.SubagentModels["explore"]; ok {
619 t.Fatalf("cleared model override should be removed, got %+v", cfg.Agent.SubagentModels)
620 }
621 if _, ok := cfg.Agent.SubagentEfforts["explore"]; ok {
622 t.Fatalf("cleared effort override should be removed, got %+v", cfg.Agent.SubagentEfforts)
623 }
624 }
625
626 func TestSubagentOverrideAliasesReadAndClear(t *testing.T) {
627 isolateDesktopUserDirs(t)
628 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
629 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
630 t.Fatalf("mkdir config dir: %v", err)
631 }
632 // A legacy underscore-key override for the security-review skill — the
633 // runtime dispatch (boot.SubagentModelKeys) honors it, so the UI must
634 // both display it and clear it.
635 if err := os.WriteFile(config.UserConfigPath(), []byte(`
636 default_model = "deepseek/deepseek-v4-flash"
637
638 [[providers]]
639 name = "deepseek"
640 kind = "openai"
641 base_url = "https://api.deepseek.com"
642 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
643 default = "deepseek-v4-flash"
644 api_key_env = "DEEPSEEK_API_KEY"
645
646 [agent.subagent_models]
647 security_review = "deepseek/deepseek-v4-pro"
648
649 [agent.subagent_efforts]
650 security_review = "max"
651 `), 0o644); err != nil {
652 t.Fatalf("write config: %v", err)
653 }
654
655 // Read side: the alias entry must surface for the hyphenated skill name.
656 cfg := config.LoadForEdit(config.UserConfigPath())
657 if got := subagentOverrideFor(cfg.Agent.SubagentModels, "security-review"); got != "deepseek/deepseek-v4-pro" {
658 t.Fatalf("alias model override not visible: %q", got)
659 }
660 if got := subagentOverrideFor(cfg.Agent.SubagentEfforts, "security-review"); got != "max" {
661 t.Fatalf("alias effort override not visible: %q", got)
662 }
663
664 // Clear side: clearing by the hyphenated name must remove the underscore
665 // entry too, or the override silently stays live at dispatch time.
666 app := NewApp()
667 if err := app.SetSubagentProfileModel("security-review", ""); err != nil {
668 t.Fatalf("clear model: %v", err)
669 }
670 if err := app.SetSubagentProfileEffort("security-review", ""); err != nil {
671 t.Fatalf("clear effort: %v", err)
672 }
673 cfg = config.LoadForEdit(config.UserConfigPath())
674 if v, ok := cfg.Agent.SubagentModels["security_review"]; ok {
675 t.Fatalf("legacy alias model entry survived the clear: %q", v)
676 }
677 if v, ok := cfg.Agent.SubagentEfforts["security_review"]; ok {
678 t.Fatalf("legacy alias effort entry survived the clear: %q", v)
679 }
680 }
681
682 func TestSetSubagentProfileModelSweepsAliasOnSet(t *testing.T) {
683 isolateDesktopUserDirs(t)
684 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
685 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
686 t.Fatalf("mkdir config dir: %v", err)
687 }
688 if err := os.WriteFile(config.UserConfigPath(), []byte(`
689 default_model = "deepseek/deepseek-v4-flash"
690
691 [[providers]]
692 name = "deepseek"
693 kind = "openai"
694 base_url = "https://api.deepseek.com"
695 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
696 default = "deepseek-v4-flash"
697 api_key_env = "DEEPSEEK_API_KEY"
698
699 [agent.subagent_models]
700 security_review = "deepseek/deepseek-v4-flash"
701 `), 0o644); err != nil {
702 t.Fatalf("write config: %v", err)
703 }
704
705 app := NewApp()
706 if err := app.SetSubagentProfileModel("security-review", "deepseek/deepseek-v4-pro"); err != nil {
707 t.Fatalf("set model: %v", err)
708 }
709 cfg := config.LoadForEdit(config.UserConfigPath())
710 if _, ok := cfg.Agent.SubagentModels["security_review"]; ok {
711 t.Fatalf("stale alias entry should be swept on set: %+v", cfg.Agent.SubagentModels)
712 }
713 if got := cfg.Agent.SubagentModels["security-review"]; got != "deepseek/deepseek-v4-pro" {
714 t.Fatalf("canonical entry = %q, want deepseek/deepseek-v4-pro", got)
715 }
716 }
717
718 func TestSetSubagentProfileModelRejectsUnknownModel(t *testing.T) {
719 isolateDesktopUserDirs(t)
720 app := NewApp()
721 if err := app.SetSubagentProfileModel("explore", "nope/does-not-exist"); err == nil {
722 t.Error("expected an error for an unresolvable model ref")
723 }
724 }
725
726 func TestSkillsSettingsSurfacesConfiguredModelOverride(t *testing.T) {
727 a := newTestSubagentApp(t)
728 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
729 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
730 t.Fatalf("mkdir config dir: %v", err)
731 }
732 if err := os.WriteFile(config.UserConfigPath(), []byte(`
733 default_model = "deepseek/deepseek-v4-flash"
734
735 [[providers]]
736 name = "deepseek"
737 kind = "openai"
738 base_url = "https://api.deepseek.com"
739 models = ["deepseek-v4-flash", "deepseek-v4-pro"]
740 default = "deepseek-v4-flash"
741 api_key_env = "DEEPSEEK_API_KEY"
742
743 [agent.subagent_models]
744 explore = "deepseek/deepseek-v4-pro"
745
746 [agent.subagent_efforts]
747 explore = "max"
748 `), 0o644); err != nil {
749 t.Fatalf("write config: %v", err)
750 }
751
752 found := false
753 for _, sk := range a.SkillsSettings().Skills {
754 if sk.Name != "explore" {
755 continue
756 }
757 found = true
758 if sk.ConfiguredModel != "deepseek/deepseek-v4-pro" || sk.ConfiguredEffort != "max" {
759 t.Fatalf("explore configured override = model:%q effort:%q", sk.ConfiguredModel, sk.ConfiguredEffort)
760 }
761 }
762 if !found {
763 t.Fatal("explore not present in SkillsSettings")
764 }
765 }
766
767 func TestDeleteSubagentProfileWrongScopeFailsSafely(t *testing.T) {
768 a := newTestSubagentApp(t)
769 if _, err := a.CreateSubagentProfile(SubagentProfileInput{
770 Name: "scoped-agent", Description: "d", SystemPrompt: "body", Scope: "global",
771 }); err != nil {
772 t.Fatalf("create: %v", err)
773 }
774 if err := a.DeleteSubagentProfile("scoped-agent", "project"); err == nil {
775 t.Fatal("expected an error deleting with the wrong scope")
776 }
777 found := false
778 for _, sk := range a.SkillsSettings().Skills {
779 if sk.Name == "scoped-agent" {
780 found = true
781 }
782 }
783 if !found {
784 t.Fatal("profile should survive a refused scope-mismatched delete")
785 }
786 }
787
788 // Profile CRUD must refuse up front while the controller has active runtime
789 // work: the post-save RefreshSkills rebuild would be rejected anyway, and a
790 // file already written (or deleted) by then strands the UI — the save reports
791 // failure, the list never refreshes, and a create retry hits "already
792 // exists". Mirrors the applyConfigChange precheck contract.
793 func TestSubagentProfileCRUDRefusesWhileControllerBusy(t *testing.T) {
794 home := t.TempDir()
795 t.Setenv("HOME", home)
796 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
797 t.Setenv("AppData", filepath.Join(home, "AppData"))
798 st := skill.New(skill.Options{HomeDir: home})
799 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
800 a := NewApp()
801 a.setTestCtrl(control.New(control.Options{Runner: runner, AllSkillStore: st, SkillStore: st}), "")
802 ctrl := a.activeCtrl()
803 defer ctrl.Close()
804
805 if _, err := a.CreateSubagentProfile(SubagentProfileInput{
806 Name: "busy-target", Description: "d", SystemPrompt: "body", Scope: "global",
807 }); err != nil {
808 t.Fatalf("create while idle: %v", err)
809 }
810
811 ctrl.Submit("work")
812 <-runner.started
813
814 if _, err := a.CreateSubagentProfile(SubagentProfileInput{
815 Name: "busy-new", Description: "d", SystemPrompt: "body", Scope: "global",
816 }); err == nil || !strings.Contains(err.Error(), "before changing subagents") {
817 t.Fatalf("busy create error = %v, want the active-work guard", err)
818 }
819 if _, ok := st.Read("busy-new"); ok {
820 t.Fatal("busy-rejected create must not write the profile file")
821 }
822 if err := a.UpdateSubagentProfile("busy-target", "global", SubagentProfileInput{
823 Description: "changed", SystemPrompt: "changed body",
824 }); err == nil || !strings.Contains(err.Error(), "before changing subagents") {
825 t.Fatalf("busy update error = %v, want the active-work guard", err)
826 }
827 if sk, ok := st.Read("busy-target"); !ok || sk.Description != "d" {
828 t.Fatalf("busy-rejected update must leave the file unchanged, got %+v ok=%v", sk, ok)
829 }
830 if err := a.DeleteSubagentProfile("busy-target", "global"); err == nil || !strings.Contains(err.Error(), "before changing subagents") {
831 t.Fatalf("busy delete error = %v, want the active-work guard", err)
832 }
833 if _, ok := st.Read("busy-target"); !ok {
834 t.Fatal("busy-rejected delete must keep the profile file")
835 }
836
837 close(runner.release)
838 waitNotRunning(t, ctrl)
839
840 if _, err := a.CreateSubagentProfile(SubagentProfileInput{
841 Name: "busy-new", Description: "d", SystemPrompt: "body", Scope: "global",
842 }); err != nil {
843 t.Fatalf("create after the turn settled: %v", err)
844 }
845 }
846
847 // A try run must be cancellable (it is otherwise an unstoppable 12-step
848 // provider loop) and single-flight: a second concurrent try is refused
849 // instead of racing the first one's cancel handle.
850 func TestTrySubagentProfileCancelAbortsRunAndIsSingleFlight(t *testing.T) {
851 isolateDesktopUserDirs(t)
852 setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
853
854 requestStarted := make(chan struct{})
855 release := make(chan struct{})
856 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
857 select {
858 case <-requestStarted:
859 default:
860 close(requestStarted)
861 }
862 select {
863 case <-r.Context().Done():
864 case <-release:
865 }
866 }))
867 defer srv.Close()
868 var releaseOnce sync.Once
869 releaseAll := func() { releaseOnce.Do(func() { close(release) }) }
870 defer releaseAll()
871
872 cfg := config.Default()
873 cfg.DefaultModel = "prov-t/model-t1"
874 cfg.Providers = []config.ProviderEntry{
875 {Name: "prov-t", Kind: "openai", BaseURL: srv.URL, Model: "model-t1", APIKeyEnv: "REASONIX_TEST_KEY"},
876 }
877 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
878 t.Fatalf("save config: %v", err)
879 }
880
881 a := NewApp()
882 done := make(chan error, 1)
883 go func() {
884 _, err := a.TrySubagentProfile(SubagentProfileInput{SystemPrompt: "be helpful"}, "do something")
885 done <- err
886 }()
887
888 select {
889 case <-requestStarted:
890 case <-time.After(10 * time.Second):
891 t.Fatal("try run never reached the provider")
892 }
893 if _, err := a.TrySubagentProfile(SubagentProfileInput{SystemPrompt: "p"}, "task"); err == nil || !strings.Contains(err.Error(), "in progress") {
894 t.Fatalf("concurrent try error = %v, want the single-flight refusal", err)
895 }
896
897 a.CancelTrySubagentProfile()
898 select {
899 case err := <-done:
900 if err == nil {
901 t.Fatal("cancelled try run should return an error")
902 }
903 case <-time.After(10 * time.Second):
904 t.Fatal("cancelled try run did not return")
905 }
906
907 // The slot frees up after the run settles: a fresh cancel is a no-op and
908 // a new try is admitted. Release the handler first so this run fails
909 // fast on the invalid empty response instead of blocking on the hang.
910 releaseAll()
911 a.CancelTrySubagentProfile()
912 if _, err := a.TrySubagentProfile(SubagentProfileInput{SystemPrompt: "p"}, "task"); err != nil && strings.Contains(err.Error(), "in progress") {
913 t.Fatalf("slot did not free after cancel: %v", err)
914 }
915 }
916
917 // SkillView.Body is the Subagents editor's prompt prefill and must ship only
918 // for runAs=subagent skills — inline skills fold references/ into Body at
919 // load time and would bloat every Capabilities/Settings fetch.
920 func TestSkillsSettingsBodyOnlyForSubagentSkills(t *testing.T) {
921 a := newTestSubagentApp(t)
922 if _, err := a.CreateSubagentProfile(SubagentProfileInput{
923 Name: "body-agent", Description: "d", SystemPrompt: "subagent prompt body", Scope: "global",
924 }); err != nil {
925 t.Fatalf("create profile: %v", err)
926 }
927 if _, err := a.activeCtrl().CreateSkill("plain-notes", skill.ScopeGlobal,
928 "---\nname: plain-notes\ndescription: notes\n---\n\nbig inline body\n"); err != nil {
929 t.Fatalf("create inline skill: %v", err)
930 }
931 var sawProfile, sawInline bool
932 for _, view := range a.SkillsSettings().Skills {
933 switch view.Name {
934 case "body-agent":
935 sawProfile = true
936 if view.Body != "subagent prompt body" {
937 t.Fatalf("subagent profile Body = %q, want the prompt", view.Body)
938 }
939 case "plain-notes":
940 sawInline = true
941 if view.Body != "" {
942 t.Fatalf("inline skill Body should be omitted, got %q", view.Body)
943 }
944 }
945 }
946 if !sawProfile || !sawInline {
947 t.Fatalf("views missing: profile=%v inline=%v", sawProfile, sawInline)
948 }
949 }
950
950 lines GO