Files
2026-07-13 13:00:08 +08:00

878 lines
32 KiB
Go

package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/skill"
)
func newTestSubagentApp(t *testing.T) *App {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
st := skill.New(skill.Options{HomeDir: home})
a := NewApp()
a.setTestCtrl(control.New(control.Options{AllSkillStore: st, SkillStore: st}), "")
t.Cleanup(func() { a.activeCtrl().Close() })
return a
}
func TestCreateSubagentProfileWritesManualInvocationSubagentSkill(t *testing.T) {
a := newTestSubagentApp(t)
path, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "my-formatter",
Description: "formats code the way I like",
SystemPrompt: "You are a code formatting assistant.",
Color: "amber",
AllowedTools: []string{"read_file", "edit_file"},
Scope: "global",
})
if err != nil {
t.Fatalf("CreateSubagentProfile: %v", err)
}
if path == "" {
t.Fatal("expected a non-empty path")
}
views := a.SkillsSettings().Skills
var found *SkillView
for i := range views {
if views[i].Name == "my-formatter" {
found = &views[i]
}
}
if found == nil {
t.Fatalf("created profile missing from SkillsSettings: %+v", views)
}
if found.RunAs != "subagent" || found.Invocation != "/my-formatter" || found.InvocationMode != "manual" || found.Color != "amber" {
t.Fatalf("profile fields wrong: %+v", found)
}
}
func TestCreateSubagentProfileRejectsBuiltinNameCollision(t *testing.T) {
a := newTestSubagentApp(t)
_, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "explore",
Description: "shadow the built-in",
SystemPrompt: "do something else entirely",
})
if err == nil {
t.Fatal("expected an error naming a built-in subagent")
}
}
func TestCreateSubagentProfileRejectsReservedSlashNames(t *testing.T) {
for _, name := range []string{"clear", "mcp__server__prompt"} {
t.Run(name, func(t *testing.T) {
a := newTestSubagentApp(t)
_, err := a.CreateSubagentProfile(SubagentProfileInput{Name: name, Description: "d", SystemPrompt: "body"})
if err == nil || !strings.Contains(err.Error(), "slash command namespace") {
t.Fatalf("CreateSubagentProfile(%q) error = %v", name, err)
}
})
}
}
func TestCreateSubagentProfileRejectsCustomCommandCollision(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
st := skill.New(skill.Options{HomeDir: home})
a := NewApp()
a.setTestCtrl(control.New(control.Options{
AllSkillStore: st,
SkillStore: st,
Commands: []command.Command{{Name: "formatter"}},
}), "")
t.Cleanup(func() { a.activeCtrl().Close() })
_, err := a.CreateSubagentProfile(SubagentProfileInput{Name: "formatter", Description: "d", SystemPrompt: "body"})
if err == nil || !strings.Contains(err.Error(), "slash command namespace") {
t.Fatalf("custom command collision error = %v", err)
}
}
func TestCreateSubagentProfileRejectsDuplicateName(t *testing.T) {
a := newTestSubagentApp(t)
input := SubagentProfileInput{Name: "dup", Description: "first", SystemPrompt: "body"}
if _, err := a.CreateSubagentProfile(input); err != nil {
t.Fatalf("first create: %v", err)
}
if _, err := a.CreateSubagentProfile(input); err == nil {
t.Fatal("expected an error creating a duplicate name")
}
}
func TestCreateSubagentProfileRequiresDescriptionAndPrompt(t *testing.T) {
a := newTestSubagentApp(t)
if _, err := a.CreateSubagentProfile(SubagentProfileInput{Name: "x", SystemPrompt: "body"}); err == nil {
t.Error("expected an error for a missing description")
}
if _, err := a.CreateSubagentProfile(SubagentProfileInput{Name: "x", Description: "d"}); err == nil {
t.Error("expected an error for a missing system prompt")
}
}
func TestCreateSubagentProfileScopeIsStrictButEmptyRemainsGlobal(t *testing.T) {
a := newTestSubagentApp(t)
path, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "default-global", Description: "d", SystemPrompt: "body",
})
if err != nil {
t.Fatalf("empty scope should preserve the legacy global default: %v", err)
}
if !strings.Contains(filepath.ToSlash(path), "/.reasonix/skills/") {
t.Fatalf("empty scope path = %q, want global Reasonix skills dir", path)
}
if _, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "bad-scope", Description: "d", SystemPrompt: "body", Scope: "custom",
}); err == nil || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("custom create scope error = %v, want explicit rejection", err)
}
for _, sk := range a.SkillsSettings().Skills {
if sk.Name == "bad-scope" {
t.Fatal("rejected custom scope must not fall back to a global file")
}
}
}
func TestUpdateAndDeleteSubagentProfileRejectUnsupportedScopeWithoutTouchingGlobal(t *testing.T) {
a := newTestSubagentApp(t)
path, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "scope-guard", Description: "original", SystemPrompt: "body", Scope: "global",
})
if err != nil {
t.Fatalf("create: %v", err)
}
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if err := a.UpdateSubagentProfile("scope-guard", "custom", SubagentProfileInput{
Description: "changed", SystemPrompt: "changed body",
}); err == nil || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("custom update scope error = %v, want explicit rejection", err)
}
if err := a.DeleteSubagentProfile("scope-guard", "anything-else"); err == nil || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("unknown delete scope error = %v, want explicit rejection", err)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("rejected delete removed the global profile: %v", err)
}
if string(after) != string(before) {
t.Fatalf("rejected custom update modified the global profile:\nbefore=%s\nafter=%s", before, after)
}
}
func TestUpdateSubagentProfileOverwritesFields(t *testing.T) {
a := newTestSubagentApp(t)
if _, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "editable-agent", Description: "v1", SystemPrompt: "old body", Color: "amber", Scope: "global",
}); err != nil {
t.Fatalf("create: %v", err)
}
if err := a.UpdateSubagentProfile("editable-agent", "global", SubagentProfileInput{
Description: "v2", SystemPrompt: "new body", Color: "blue", Model: "deepseek/deepseek-pro", AllowedTools: []string{"read_file"},
}); err != nil {
t.Fatalf("UpdateSubagentProfile: %v", err)
}
var found *SkillView
for _, sk := range a.SkillsSettings().Skills {
if sk.Name == "editable-agent" {
found = &sk
}
}
if found == nil {
t.Fatal("editable-agent missing after update")
}
if found.Description != "v2" || found.Color != "blue" || found.Model != "deepseek/deepseek-pro" || found.Invocation != "/editable-agent" || found.InvocationMode != "manual" || found.RunAs != "subagent" {
t.Fatalf("update did not apply as expected: %+v", found)
}
if len(found.AllowedTools) != 1 || found.AllowedTools[0] != "read_file" {
t.Fatalf("AllowedTools not updated: %v", found.AllowedTools)
}
}
func TestUpdateSubagentProfileRequiresDescriptionAndPrompt(t *testing.T) {
a := newTestSubagentApp(t)
if _, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "editable-agent2", Description: "v1", SystemPrompt: "old body", Scope: "global",
}); err != nil {
t.Fatalf("create: %v", err)
}
if err := a.UpdateSubagentProfile("editable-agent2", "global", SubagentProfileInput{SystemPrompt: "new body"}); err == nil {
t.Error("expected an error for a missing description")
}
if err := a.UpdateSubagentProfile("editable-agent2", "global", SubagentProfileInput{Description: "d"}); err == nil {
t.Error("expected an error for a missing system prompt")
}
}
func TestUpdateSubagentProfileRefusesNonManualSkill(t *testing.T) {
a := newTestSubagentApp(t)
home := os.Getenv("HOME")
// A hand-authored subagent skill without invocation: manual — the exact
// shape the reviewer flagged: editing it here would silently drop fields.
dir := filepath.Join(home, ".reasonix", "skills", "hand-authored")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"),
[]byte("---\ndescription: hand written\nrunAs: subagent\nread-only: true\n---\nbody"), 0o644); err != nil {
t.Fatal(err)
}
err := a.UpdateSubagentProfile("hand-authored", "global", SubagentProfileInput{Description: "x", SystemPrompt: "y"})
if err == nil {
t.Fatal("expected refusal for a non-manual skill")
}
if !strings.Contains(err.Error(), "manual") {
t.Fatalf("error should explain the manual-invocation rule, got: %v", err)
}
}
func TestUpdateSubagentProfileRefusesUnmanagedFrontmatter(t *testing.T) {
a := newTestSubagentApp(t)
home := os.Getenv("HOME")
// invocation: manual but carrying read-only — dropping it on save would
// turn a read-only agent writable (boot.go picks the registry from it).
dir := filepath.Join(home, ".reasonix", "skills", "manual-readonly")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"),
[]byte("---\ndescription: locked down\nrunAs: subagent\ninvocation: manual\nread-only: true\n---\nbody"), 0o644); err != nil {
t.Fatal(err)
}
err := a.UpdateSubagentProfile("manual-readonly", "global", SubagentProfileInput{Description: "x", SystemPrompt: "y"})
if err == nil {
t.Fatal("expected refusal for unmanaged frontmatter keys")
}
if !strings.Contains(err.Error(), "read-only") {
t.Fatalf("error should name the unmanaged key, got: %v", err)
}
// The file must be untouched by the refused edit.
raw, rerr := os.ReadFile(filepath.Join(dir, "SKILL.md"))
if rerr != nil || !strings.Contains(string(raw), "read-only: true") {
t.Fatalf("refused edit must not modify the file, got: %s (%v)", raw, rerr)
}
}
// TestUpdateSubagentProfileRefusesManualInlineSkill pins the runAs guard: a
// hand-authored manual-invocation INLINE skill carries only editor-managed
// frontmatter keys, so without an explicit runAs check the update path would
// rewrite it with runAs: subagent — silently converting an inline playbook
// into an isolated subagent.
func TestUpdateSubagentProfileRefusesManualInlineSkill(t *testing.T) {
a := newTestSubagentApp(t)
home := os.Getenv("HOME")
dir := filepath.Join(home, ".reasonix", "skills", "manual-inline")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"),
[]byte("---\ndescription: quiet inline playbook\ninvocation: manual\n---\ninline body"), 0o644); err != nil {
t.Fatal(err)
}
err := a.UpdateSubagentProfile("manual-inline", "global", SubagentProfileInput{Description: "x", SystemPrompt: "y"})
if err == nil {
t.Fatal("expected refusal for a manual inline skill")
}
if !strings.Contains(err.Error(), "subagent") {
t.Fatalf("error should explain the runAs rule, got: %v", err)
}
raw, rerr := os.ReadFile(filepath.Join(dir, "SKILL.md"))
if rerr != nil || strings.Contains(string(raw), "runAs: subagent") {
t.Fatalf("refused edit must not convert the inline skill, got: %s (%v)", raw, rerr)
}
}
// TestDeleteSubagentProfileRefusesNonProfileSkill pins the delete guard: the
// bridge method must not remove a user skill this page never owned, even when
// called directly with a matching name+scope.
func TestDeleteSubagentProfileRefusesNonProfileSkill(t *testing.T) {
a := newTestSubagentApp(t)
home := os.Getenv("HOME")
dir := filepath.Join(home, ".reasonix", "skills", "hand-skill")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
file := filepath.Join(dir, "SKILL.md")
if err := os.WriteFile(file,
[]byte("---\ndescription: precious hand-authored playbook\nrunAs: subagent\ntriggers: deploy\n---\nbody"), 0o644); err != nil {
t.Fatal(err)
}
if err := a.DeleteSubagentProfile("hand-skill", "global"); err == nil {
t.Fatal("expected refusal deleting a non-profile skill")
}
if _, err := os.Stat(file); err != nil {
t.Fatalf("refused delete must leave the file in place: %v", err)
}
}
func TestUpdateSubagentProfileRefusesExpandedReferences(t *testing.T) {
a := newTestSubagentApp(t)
home := os.Getenv("HOME")
dir := filepath.Join(home, ".reasonix", "skills", "with-refs")
if err := os.MkdirAll(filepath.Join(dir, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"),
[]byte("---\ndescription: has refs\nrunAs: subagent\ninvocation: manual\n---\nbody"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "references", "extra.md"), []byte("depth material"), 0o644); err != nil {
t.Fatal(err)
}
err := a.UpdateSubagentProfile("with-refs", "global", SubagentProfileInput{Description: "x", SystemPrompt: "y"})
if err == nil {
t.Fatal("expected refusal for a profile with a references/ dir")
}
if !strings.Contains(err.Error(), "references") {
t.Fatalf("error should name the references dir, got: %v", err)
}
}
func TestUpdateSubagentProfileWrongScopeFailsSafely(t *testing.T) {
a := newTestSubagentApp(t)
if _, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "editable-agent3", Description: "v1", SystemPrompt: "old body", Scope: "global",
}); err != nil {
t.Fatalf("create: %v", err)
}
if err := a.UpdateSubagentProfile("editable-agent3", "project", SubagentProfileInput{Description: "v2", SystemPrompt: "new body"}); err == nil {
t.Fatal("expected an error updating with the wrong scope")
}
for _, sk := range a.SkillsSettings().Skills {
if sk.Name == "editable-agent3" && sk.Description != "v1" {
t.Fatalf("profile should be unchanged after a refused scope-mismatched update, got description=%q", sk.Description)
}
}
}
func TestTrySubagentRegistryIsReadOnly(t *testing.T) {
reg := trySubagentToolRegistry(config.Default(), t.TempDir(), nil)
for _, writer := range []string{"write_file", "edit_file", "multi_edit", "move_file", "notebook_edit", "delete_range", "delete_symbol"} {
if _, ok := reg.Get(writer); ok {
t.Errorf("try registry should strip writer tool %q; got %v", writer, reg.Names())
}
}
for _, meta := range []string{"task", "run_skill", "install_skill", "install_source", "parallel_tasks"} {
if _, ok := reg.Get(meta); ok {
t.Errorf("try registry should strip meta/delegation tool %q; got %v", meta, reg.Names())
}
}
if _, ok := reg.Get("read_file"); !ok {
t.Fatalf("try registry should keep read_file; got %v", reg.Names())
}
}
func TestTrySubagentRegistryBashEnforcesReadOnlyPolicy(t *testing.T) {
reg := trySubagentToolRegistry(config.Default(), t.TempDir(), nil)
bash, ok := reg.Get("bash")
if !ok {
t.Fatalf("try registry should keep bash; got %v", reg.Names())
}
if !bash.ReadOnly() {
t.Fatal("try bash should report ReadOnly=true (plan-mode-safe wrapper)")
}
out, err := bash.Execute(context.Background(), json.RawMessage(`{"command":"rm -rf /tmp/x"}`))
if err != nil {
t.Fatalf("blocked command should return a message, not an error: %v", err)
}
if !strings.Contains(strings.ToLower(out), "plan mode") && !strings.Contains(strings.ToLower(out), "blocked") && !strings.Contains(strings.ToLower(out), "not allowed") {
t.Fatalf("write-capable command should be blocked by the read-only policy, got: %s", out)
}
}
func TestTrySubagentRegistryHonorsAllowedTools(t *testing.T) {
reg := trySubagentToolRegistry(config.Default(), t.TempDir(), []string{"read_file", "grep", "write_file"})
if _, ok := reg.Get("read_file"); !ok {
t.Fatalf("allowlisted read_file missing; got %v", reg.Names())
}
if _, ok := reg.Get("write_file"); ok {
t.Fatalf("write_file must stay stripped even when allowlisted; got %v", reg.Names())
}
if _, ok := reg.Get("ls"); ok {
t.Fatalf("ls not in the allowlist, should be absent; got %v", reg.Names())
}
}
// TestTrySubagentRegistryResolvesRelativePathsAgainstWorkspaceRoot pins the
// multi-workspace contract: the try registry's tools must resolve relative
// paths against the ACTIVE TAB's root, not the desktop process CWD. The
// process working directory is global and, in a multi-tab session, points at
// whichever project the app happened to start in — a try run resolving
// against it could read (and send to the provider) a different project than
// the one on screen.
func TestTrySubagentRegistryResolvesRelativePathsAgainstWorkspaceRoot(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "marker.txt"), []byte("workspace-bound"), 0o644); err != nil {
t.Fatal(err)
}
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if cwd == root {
t.Fatal("test requires process CWD != workspace root")
}
reg := trySubagentToolRegistry(config.Default(), root, nil)
rf, ok := reg.Get("read_file")
if !ok {
t.Fatalf("read_file missing; got %v", reg.Names())
}
out, err := rf.Execute(context.Background(), json.RawMessage(`{"path":"marker.txt"}`))
if err != nil {
t.Fatalf("relative read against the workspace root failed (resolved against process CWD?): %v", err)
}
if !strings.Contains(out, "workspace-bound") {
t.Fatalf("relative read returned wrong content: %s", out)
}
ls, ok := reg.Get("ls")
if !ok {
t.Fatalf("ls missing; got %v", reg.Names())
}
out, err = ls.Execute(context.Background(), json.RawMessage(`{"path":"."}`))
if err != nil {
t.Fatalf("relative ls against the workspace root failed: %v", err)
}
if !strings.Contains(out, "marker.txt") {
t.Fatalf("ls of workspace root missing marker.txt (listed process CWD instead?): %s", out)
}
}
func TestTrySubagentProfileRequiresTaskAndPrompt(t *testing.T) {
isolateDesktopUserDirs(t)
a := NewApp()
if _, err := a.TrySubagentProfile(SubagentProfileInput{SystemPrompt: "be helpful"}, ""); err == nil {
t.Error("expected an error for a missing task")
}
if _, err := a.TrySubagentProfile(SubagentProfileInput{}, "do something"); err == nil {
t.Error("expected an error for a missing system prompt")
}
}
func TestTrySubagentProfileRejectsUnknownModel(t *testing.T) {
isolateDesktopUserDirs(t)
a := NewApp()
_, err := a.TrySubagentProfile(SubagentProfileInput{
SystemPrompt: "be helpful",
Model: "nope/does-not-exist",
}, "do something")
if err == nil {
t.Error("expected an error for an unresolvable model ref")
}
}
func TestDeleteSubagentProfileRemovesIt(t *testing.T) {
a := newTestSubagentApp(t)
if _, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "temp-agent", Description: "d", SystemPrompt: "body", Scope: "global",
}); err != nil {
t.Fatalf("create: %v", err)
}
if err := a.DeleteSubagentProfile("temp-agent", "global"); err != nil {
t.Fatalf("DeleteSubagentProfile: %v", err)
}
for _, sk := range a.SkillsSettings().Skills {
if sk.Name == "temp-agent" {
t.Fatal("deleted profile still present")
}
}
}
func TestSetSubagentProfileModelAndEffortRoundTripPerName(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
default_model = "deepseek/deepseek-v4-flash"
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://api.deepseek.com"
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
default = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
app := NewApp()
if err := app.SetSubagentProfileModel("explore", "deepseek/deepseek-v4-pro"); err != nil {
t.Fatalf("SetSubagentProfileModel: %v", err)
}
if err := app.SetSubagentProfileEffort("explore", "max"); err != nil {
t.Fatalf("SetSubagentProfileEffort: %v", err)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if cfg.Agent.SubagentModels["explore"] != "deepseek/deepseek-v4-pro" || cfg.Agent.SubagentEfforts["explore"] != "max" {
t.Fatalf("saved per-name overrides = model:%q effort:%q", cfg.Agent.SubagentModels["explore"], cfg.Agent.SubagentEfforts["explore"])
}
// A different skill name must be unaffected — this is a per-name map, not
// a global default.
if cfg.Agent.SubagentModel != "" || cfg.Agent.SubagentEffort != "" {
t.Fatalf("global subagent defaults should be untouched: model:%q effort:%q", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)
}
// Clearing (empty ref/level) removes the map entry rather than storing "".
if err := app.SetSubagentProfileModel("explore", ""); err != nil {
t.Fatalf("clear SetSubagentProfileModel: %v", err)
}
if err := app.SetSubagentProfileEffort("explore", ""); err != nil {
t.Fatalf("clear SetSubagentProfileEffort: %v", err)
}
cfg = config.LoadForEdit(config.UserConfigPath())
if _, ok := cfg.Agent.SubagentModels["explore"]; ok {
t.Fatalf("cleared model override should be removed, got %+v", cfg.Agent.SubagentModels)
}
if _, ok := cfg.Agent.SubagentEfforts["explore"]; ok {
t.Fatalf("cleared effort override should be removed, got %+v", cfg.Agent.SubagentEfforts)
}
}
func TestSubagentOverrideAliasesReadAndClear(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
// A legacy underscore-key override for the security-review skill — the
// runtime dispatch (boot.SubagentModelKeys) honors it, so the UI must
// both display it and clear it.
if err := os.WriteFile(config.UserConfigPath(), []byte(`
default_model = "deepseek/deepseek-v4-flash"
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://api.deepseek.com"
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
default = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
[agent.subagent_models]
security_review = "deepseek/deepseek-v4-pro"
[agent.subagent_efforts]
security_review = "max"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
// Read side: the alias entry must surface for the hyphenated skill name.
cfg := config.LoadForEdit(config.UserConfigPath())
if got := subagentOverrideFor(cfg.Agent.SubagentModels, "security-review"); got != "deepseek/deepseek-v4-pro" {
t.Fatalf("alias model override not visible: %q", got)
}
if got := subagentOverrideFor(cfg.Agent.SubagentEfforts, "security-review"); got != "max" {
t.Fatalf("alias effort override not visible: %q", got)
}
// Clear side: clearing by the hyphenated name must remove the underscore
// entry too, or the override silently stays live at dispatch time.
app := NewApp()
if err := app.SetSubagentProfileModel("security-review", ""); err != nil {
t.Fatalf("clear model: %v", err)
}
if err := app.SetSubagentProfileEffort("security-review", ""); err != nil {
t.Fatalf("clear effort: %v", err)
}
cfg = config.LoadForEdit(config.UserConfigPath())
if v, ok := cfg.Agent.SubagentModels["security_review"]; ok {
t.Fatalf("legacy alias model entry survived the clear: %q", v)
}
if v, ok := cfg.Agent.SubagentEfforts["security_review"]; ok {
t.Fatalf("legacy alias effort entry survived the clear: %q", v)
}
}
func TestSetSubagentProfileModelSweepsAliasOnSet(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
default_model = "deepseek/deepseek-v4-flash"
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://api.deepseek.com"
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
default = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
[agent.subagent_models]
security_review = "deepseek/deepseek-v4-flash"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
app := NewApp()
if err := app.SetSubagentProfileModel("security-review", "deepseek/deepseek-v4-pro"); err != nil {
t.Fatalf("set model: %v", err)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if _, ok := cfg.Agent.SubagentModels["security_review"]; ok {
t.Fatalf("stale alias entry should be swept on set: %+v", cfg.Agent.SubagentModels)
}
if got := cfg.Agent.SubagentModels["security-review"]; got != "deepseek/deepseek-v4-pro" {
t.Fatalf("canonical entry = %q, want deepseek/deepseek-v4-pro", got)
}
}
func TestSetSubagentProfileModelRejectsUnknownModel(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
if err := app.SetSubagentProfileModel("explore", "nope/does-not-exist"); err == nil {
t.Error("expected an error for an unresolvable model ref")
}
}
func TestSkillsSettingsSurfacesConfiguredModelOverride(t *testing.T) {
a := newTestSubagentApp(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
default_model = "deepseek/deepseek-v4-flash"
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://api.deepseek.com"
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
default = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
[agent.subagent_models]
explore = "deepseek/deepseek-v4-pro"
[agent.subagent_efforts]
explore = "max"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
found := false
for _, sk := range a.SkillsSettings().Skills {
if sk.Name != "explore" {
continue
}
found = true
if sk.ConfiguredModel != "deepseek/deepseek-v4-pro" || sk.ConfiguredEffort != "max" {
t.Fatalf("explore configured override = model:%q effort:%q", sk.ConfiguredModel, sk.ConfiguredEffort)
}
}
if !found {
t.Fatal("explore not present in SkillsSettings")
}
}
func TestDeleteSubagentProfileWrongScopeFailsSafely(t *testing.T) {
a := newTestSubagentApp(t)
if _, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "scoped-agent", Description: "d", SystemPrompt: "body", Scope: "global",
}); err != nil {
t.Fatalf("create: %v", err)
}
if err := a.DeleteSubagentProfile("scoped-agent", "project"); err == nil {
t.Fatal("expected an error deleting with the wrong scope")
}
found := false
for _, sk := range a.SkillsSettings().Skills {
if sk.Name == "scoped-agent" {
found = true
}
}
if !found {
t.Fatal("profile should survive a refused scope-mismatched delete")
}
}
// Profile CRUD must refuse up front while the controller has active runtime
// work: the post-save RefreshSkills rebuild would be rejected anyway, and a
// file already written (or deleted) by then strands the UI — the save reports
// failure, the list never refreshes, and a create retry hits "already
// exists". Mirrors the applyConfigChange precheck contract.
func TestSubagentProfileCRUDRefusesWhileControllerBusy(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
st := skill.New(skill.Options{HomeDir: home})
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
a := NewApp()
a.setTestCtrl(control.New(control.Options{Runner: runner, AllSkillStore: st, SkillStore: st}), "")
ctrl := a.activeCtrl()
defer ctrl.Close()
if _, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "busy-target", Description: "d", SystemPrompt: "body", Scope: "global",
}); err != nil {
t.Fatalf("create while idle: %v", err)
}
ctrl.Submit("work")
<-runner.started
if _, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "busy-new", Description: "d", SystemPrompt: "body", Scope: "global",
}); err == nil || !strings.Contains(err.Error(), "before changing subagents") {
t.Fatalf("busy create error = %v, want the active-work guard", err)
}
if _, ok := st.Read("busy-new"); ok {
t.Fatal("busy-rejected create must not write the profile file")
}
if err := a.UpdateSubagentProfile("busy-target", "global", SubagentProfileInput{
Description: "changed", SystemPrompt: "changed body",
}); err == nil || !strings.Contains(err.Error(), "before changing subagents") {
t.Fatalf("busy update error = %v, want the active-work guard", err)
}
if sk, ok := st.Read("busy-target"); !ok || sk.Description != "d" {
t.Fatalf("busy-rejected update must leave the file unchanged, got %+v ok=%v", sk, ok)
}
if err := a.DeleteSubagentProfile("busy-target", "global"); err == nil || !strings.Contains(err.Error(), "before changing subagents") {
t.Fatalf("busy delete error = %v, want the active-work guard", err)
}
if _, ok := st.Read("busy-target"); !ok {
t.Fatal("busy-rejected delete must keep the profile file")
}
close(runner.release)
waitNotRunning(t, ctrl)
if _, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "busy-new", Description: "d", SystemPrompt: "body", Scope: "global",
}); err != nil {
t.Fatalf("create after the turn settled: %v", err)
}
}
// A try run must be cancellable (it is otherwise an unstoppable 12-step
// provider loop) and single-flight: a second concurrent try is refused
// instead of racing the first one's cancel handle.
func TestTrySubagentProfileCancelAbortsRunAndIsSingleFlight(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
requestStarted := make(chan struct{})
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-requestStarted:
default:
close(requestStarted)
}
select {
case <-r.Context().Done():
case <-release:
}
}))
defer srv.Close()
var releaseOnce sync.Once
releaseAll := func() { releaseOnce.Do(func() { close(release) }) }
defer releaseAll()
cfg := config.Default()
cfg.DefaultModel = "prov-t/model-t1"
cfg.Providers = []config.ProviderEntry{
{Name: "prov-t", Kind: "openai", BaseURL: srv.URL, Model: "model-t1", APIKeyEnv: "REASONIX_TEST_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
a := NewApp()
done := make(chan error, 1)
go func() {
_, err := a.TrySubagentProfile(SubagentProfileInput{SystemPrompt: "be helpful"}, "do something")
done <- err
}()
select {
case <-requestStarted:
case <-time.After(10 * time.Second):
t.Fatal("try run never reached the provider")
}
if _, err := a.TrySubagentProfile(SubagentProfileInput{SystemPrompt: "p"}, "task"); err == nil || !strings.Contains(err.Error(), "in progress") {
t.Fatalf("concurrent try error = %v, want the single-flight refusal", err)
}
a.CancelTrySubagentProfile()
select {
case err := <-done:
if err == nil {
t.Fatal("cancelled try run should return an error")
}
case <-time.After(10 * time.Second):
t.Fatal("cancelled try run did not return")
}
// The slot frees up after the run settles: a fresh cancel is a no-op and
// a new try is admitted. Release the handler first so this run fails
// fast on the invalid empty response instead of blocking on the hang.
releaseAll()
a.CancelTrySubagentProfile()
if _, err := a.TrySubagentProfile(SubagentProfileInput{SystemPrompt: "p"}, "task"); err != nil && strings.Contains(err.Error(), "in progress") {
t.Fatalf("slot did not free after cancel: %v", err)
}
}
// SkillView.Body is the Subagents editor's prompt prefill and must ship only
// for runAs=subagent skills — inline skills fold references/ into Body at
// load time and would bloat every Capabilities/Settings fetch.
func TestSkillsSettingsBodyOnlyForSubagentSkills(t *testing.T) {
a := newTestSubagentApp(t)
if _, err := a.CreateSubagentProfile(SubagentProfileInput{
Name: "body-agent", Description: "d", SystemPrompt: "subagent prompt body", Scope: "global",
}); err != nil {
t.Fatalf("create profile: %v", err)
}
if _, err := a.activeCtrl().CreateSkill("plain-notes", skill.ScopeGlobal,
"---\nname: plain-notes\ndescription: notes\n---\n\nbig inline body\n"); err != nil {
t.Fatalf("create inline skill: %v", err)
}
var sawProfile, sawInline bool
for _, view := range a.SkillsSettings().Skills {
switch view.Name {
case "body-agent":
sawProfile = true
if view.Body != "subagent prompt body" {
t.Fatalf("subagent profile Body = %q, want the prompt", view.Body)
}
case "plain-notes":
sawInline = true
if view.Body != "" {
t.Fatalf("inline skill Body should be omitted, got %q", view.Body)
}
}
}
if !sawProfile || !sawInline {
t.Fatalf("views missing: profile=%v inline=%v", sawProfile, sawInline)
}
}