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