fix(agent): harden subagent swarm dispatch
- commit
- 26cdde58d5d88fa4c84360c2cb1a9397b0689781
- author
- brice · · 2026-08-04 04:33 UTC
- parent
- e6dacd2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +139 -23
M packages/agent/build.go +4 -1
| @@ -309,7 +309,10 @@ func findSubagentProfile(cwd, name string) (*subagents.Profile, error) { | |||
| 309 | 309 | return nil, nil | |
| 310 | 310 | } | |
| 311 | 311 | home, _ := os.UserHomeDir() | |
| 312 | - | profiles, _ := subagents.Discover(cwd, home) | |
| 312 | + | profiles, discoveryErrors := subagents.Discover(cwd, home) | |
| 313 | + | if len(discoveryErrors) > 0 { | |
| 314 | + | return nil, fmt.Errorf("discover subagent profiles: %d profile discovery error(s)", len(discoveryErrors)) | |
| 315 | + | } | |
| 313 | 316 | profile := subagents.Find(profiles, name) | |
| 314 | 317 | if profile == nil { | |
| 315 | 318 | return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and compatibility locations)", name) | |
| @@ -309,7 +309,10 @@ func findSubagentProfile(cwd, name string) (*subagents.Profile, error) { | |||||
| 309 | return nil, nil | 309 | return nil, nil | ||
| 310 | } | 310 | } | ||
| 311 | home, _ := os.UserHomeDir() | 311 | home, _ := os.UserHomeDir() | ||
| 312 | - | profiles, _ := subagents.Discover(cwd, home) | 312 | + | profiles, discoveryErrors := subagents.Discover(cwd, home) |
| 313 | + | if len(discoveryErrors) > 0 { | |||
| 314 | + | return nil, fmt.Errorf("discover subagent profiles: %d profile discovery error(s)", len(discoveryErrors)) | |||
| 315 | + | } | |||
| 313 | profile := subagents.Find(profiles, name) | 316 | profile := subagents.Find(profiles, name) | ||
| 314 | if profile == nil { | 317 | if profile == nil { | ||
| 315 | return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and compatibility locations)", name) | 318 | return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and compatibility locations)", name) | ||
M packages/agent/build_test.go +19 -0
| @@ -59,6 +59,25 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |||
| 59 | 59 | } | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | + | func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) { | |
| 63 | + | profileSource := filepath.Join(t.TempDir(), "profiles.md") | |
| 64 | + | if err := os.WriteFile(profileSource, []byte("not a directory"), 0o600); err != nil { | |
| 65 | + | t.Fatal(err) | |
| 66 | + | } | |
| 67 | + | t.Setenv("ZOT_AGENT_PROFILES", profileSource) | |
| 68 | + | ||
| 69 | + | profile, err := findSubagentProfile("", "reviewer") | |
| 70 | + | if profile != nil { | |
| 71 | + | t.Fatalf("profile = %#v, want nil", profile) | |
| 72 | + | } | |
| 73 | + | if err == nil || !strings.Contains(err.Error(), "discover subagent profiles") { | |
| 74 | + | t.Fatalf("error = %v, want discovery failure", err) | |
| 75 | + | } | |
| 76 | + | if strings.Contains(err.Error(), profileSource) { | |
| 77 | + | t.Fatalf("error leaked profile path: %v", err) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 62 | 81 | func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) { | |
| 63 | 82 | root := t.TempDir() | |
| 64 | 83 | home := filepath.Join(root, "home") | |
| @@ -59,6 +59,25 @@ func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |||||
| 59 | } | 59 | } | ||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | + | func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) { | |||
| 63 | + | profileSource := filepath.Join(t.TempDir(), "profiles.md") | |||
| 64 | + | if err := os.WriteFile(profileSource, []byte("not a directory"), 0o600); err != nil { | |||
| 65 | + | t.Fatal(err) | |||
| 66 | + | } | |||
| 67 | + | t.Setenv("ZOT_AGENT_PROFILES", profileSource) | |||
| 68 | + | ||||
| 69 | + | profile, err := findSubagentProfile("", "reviewer") | |||
| 70 | + | if profile != nil { | |||
| 71 | + | t.Fatalf("profile = %#v, want nil", profile) | |||
| 72 | + | } | |||
| 73 | + | if err == nil || !strings.Contains(err.Error(), "discover subagent profiles") { | |||
| 74 | + | t.Fatalf("error = %v, want discovery failure", err) | |||
| 75 | + | } | |||
| 76 | + | if strings.Contains(err.Error(), profileSource) { | |||
| 77 | + | t.Fatalf("error leaked profile path: %v", err) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 62 | func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) { | 81 | func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) { | ||
| 63 | root := t.TempDir() | 82 | root := t.TempDir() | ||
| 64 | home := filepath.Join(root, "home") | 83 | home := filepath.Join(root, "home") | ||
M packages/agent/cli.go +3 -0
| @@ -1121,6 +1121,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1121 | 1121 | wasJailed := sharedSandbox != nil && sharedSandbox.Locked() | |
| 1122 | 1122 | args.CWD = absPath | |
| 1123 | 1123 | r.CWD = absPath | |
| 1124 | + | if swarmMgr != nil { | |
| 1125 | + | swarmMgr.SetRepoRoot(absPath) | |
| 1126 | + | } | |
| 1124 | 1127 | if args.PermissionSet != nil { | |
| 1125 | 1128 | expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir) | |
| 1126 | 1129 | args.PermissionSet = &expanded | |
| @@ -1121,6 +1121,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1121 | wasJailed := sharedSandbox != nil && sharedSandbox.Locked() | 1121 | wasJailed := sharedSandbox != nil && sharedSandbox.Locked() | ||
| 1122 | args.CWD = absPath | 1122 | args.CWD = absPath | ||
| 1123 | r.CWD = absPath | 1123 | r.CWD = absPath | ||
| 1124 | + | if swarmMgr != nil { | |||
| 1125 | + | swarmMgr.SetRepoRoot(absPath) | |||
| 1126 | + | } | |||
| 1124 | if args.PermissionSet != nil { | 1127 | if args.PermissionSet != nil { | ||
| 1125 | expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir) | 1128 | expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir) | ||
| 1126 | args.PermissionSet = &expanded | 1129 | args.PermissionSet = &expanded | ||
M packages/agent/modes/swarm_slash.go +25 -15
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | 10 | "github.com/patriceckhart/zot/packages/tui" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -126,6 +127,14 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 126 | 127 | if reasoning == "" { | |
| 127 | 128 | reasoning = i.cfg.Reasoning | |
| 128 | 129 | } | |
| 130 | + | if reasoning != "" { | |
| 131 | + | normalizedReasoning, err := normalizeSpawnReasoning(reasoning) | |
| 132 | + | if err != nil { | |
| 133 | + | i.swarmStatus("", "spawn: "+err.Error()) | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | reasoning = normalizedReasoning | |
| 137 | + | } | |
| 129 | 138 | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |
| 130 | 139 | Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent, | |
| 131 | 140 | }) | |
| @@ -243,22 +252,23 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 243 | 252 | } | |
| 244 | 253 | } | |
| 245 | 254 | ||
| 255 | + | // normalizeSpawnReasoning validates and canonicalizes the reasoning levels | |
| 256 | + | // accepted by ParseArgs before a child process is launched. | |
| 257 | + | func normalizeSpawnReasoning(value string) (string, error) { | |
| 258 | + | raw := strings.ToLower(strings.TrimSpace(value)) | |
| 259 | + | switch raw { | |
| 260 | + | case "", "off": | |
| 261 | + | return raw, nil | |
| 262 | + | case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max": | |
| 263 | + | return provider.NormalizeReasoning(raw), nil | |
| 264 | + | default: | |
| 265 | + | return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max") | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 246 | 269 | // parseSpawnFlags consumes leading profile/model/provider/reasoning flags | |
| 247 | - | // from s and returns them along with the remaining task body. | |
| 248 | - | // We deliberately only honour LEADING flags so a task like "check | |
| 249 | - | // --model lookup" doesn't accidentally swallow part of its prose as | |
| 250 | - | // the model name. | |
| 251 | - | // | |
| 252 | - | // Recognised forms: | |
| 253 | - | // | |
| 254 | - | // --agent X two-token form | |
| 255 | - | // --agent=X single-token form | |
| 256 | - | // --model X two-token form | |
| 257 | - | // --model=X single-token form | |
| 258 | - | // --provider X two-token form | |
| 259 | - | // --provider=X single-token form | |
| 260 | - | // --reasoning X two-token form (also --thinking) | |
| 261 | - | // --reasoning=X single-token form | |
| 270 | + | // using both separate-value and equals forms. Flags after the task remain | |
| 271 | + | // part of the task text. | |
| 262 | 272 | func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) { | |
| 263 | 273 | fields := strings.Fields(s) | |
| 264 | 274 | i := 0 | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | 7 | ||||
| 8 | "github.com/patriceckhart/zot/packages/agent/swarm" | 8 | "github.com/patriceckhart/zot/packages/agent/swarm" | ||
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 9 | "github.com/patriceckhart/zot/packages/tui" | 10 | "github.com/patriceckhart/zot/packages/tui" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| @@ -126,6 +127,14 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||||
| 126 | if reasoning == "" { | 127 | if reasoning == "" { | ||
| 127 | reasoning = i.cfg.Reasoning | 128 | reasoning = i.cfg.Reasoning | ||
| 128 | } | 129 | } | ||
| 130 | + | if reasoning != "" { | |||
| 131 | + | normalizedReasoning, err := normalizeSpawnReasoning(reasoning) | |||
| 132 | + | if err != nil { | |||
| 133 | + | i.swarmStatus("", "spawn: "+err.Error()) | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | reasoning = normalizedReasoning | |||
| 137 | + | } | |||
| 129 | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | 138 | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | ||
| 130 | Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent, | 139 | Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent, | ||
| 131 | }) | 140 | }) | ||
| @@ -243,22 +252,23 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||||
| 243 | } | 252 | } | ||
| 244 | } | 253 | } | ||
| 245 | 254 | ||||
| 255 | + | // normalizeSpawnReasoning validates and canonicalizes the reasoning levels | |||
| 256 | + | // accepted by ParseArgs before a child process is launched. | |||
| 257 | + | func normalizeSpawnReasoning(value string) (string, error) { | |||
| 258 | + | raw := strings.ToLower(strings.TrimSpace(value)) | |||
| 259 | + | switch raw { | |||
| 260 | + | case "", "off": | |||
| 261 | + | return raw, nil | |||
| 262 | + | case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max": | |||
| 263 | + | return provider.NormalizeReasoning(raw), nil | |||
| 264 | + | default: | |||
| 265 | + | return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max") | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 246 | // parseSpawnFlags consumes leading profile/model/provider/reasoning flags | 269 | // parseSpawnFlags consumes leading profile/model/provider/reasoning flags | ||
| 247 | - | // from s and returns them along with the remaining task body. | 270 | + | // using both separate-value and equals forms. Flags after the task remain |
| 248 | - | // We deliberately only honour LEADING flags so a task like "check | 271 | + | // part of the task text. |
| 249 | - | // --model lookup" doesn't accidentally swallow part of its prose as | |||
| 250 | - | // the model name. | |||
| 251 | - | // | |||
| 252 | - | // Recognised forms: | |||
| 253 | - | // | |||
| 254 | - | // --agent X two-token form | |||
| 255 | - | // --agent=X single-token form | |||
| 256 | - | // --model X two-token form | |||
| 257 | - | // --model=X single-token form | |||
| 258 | - | // --provider X two-token form | |||
| 259 | - | // --provider=X single-token form | |||
| 260 | - | // --reasoning X two-token form (also --thinking) | |||
| 261 | - | // --reasoning=X single-token form | |||
| 262 | func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) { | 272 | func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) { | ||
| 263 | fields := strings.Fields(s) | 273 | fields := strings.Fields(s) | ||
| 264 | i := 0 | 274 | i := 0 | ||
M packages/agent/modes/swarm_slash_test.go +25 -0
| @@ -175,6 +175,31 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) { | |||
| 175 | 175 | } | |
| 176 | 176 | } | |
| 177 | 177 | ||
| 178 | + | func TestNormalizeSpawnReasoning(t *testing.T) { | |
| 179 | + | cases := []struct { | |
| 180 | + | in, want string | |
| 181 | + | ok bool | |
| 182 | + | }{ | |
| 183 | + | {in: "OFF", want: "off", ok: true}, | |
| 184 | + | {in: "minimal", want: "minimum", ok: true}, | |
| 185 | + | {in: "maximum", want: "xhigh", ok: true}, | |
| 186 | + | {in: "MAX", want: "max", ok: true}, | |
| 187 | + | {in: "bogus", ok: false}, | |
| 188 | + | } | |
| 189 | + | for _, tc := range cases { | |
| 190 | + | got, err := normalizeSpawnReasoning(tc.in) | |
| 191 | + | if tc.ok { | |
| 192 | + | if err != nil || got != tc.want { | |
| 193 | + | t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want) | |
| 194 | + | } | |
| 195 | + | continue | |
| 196 | + | } | |
| 197 | + | if err == nil { | |
| 198 | + | t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 178 | 203 | func TestParseSpawnFlags(t *testing.T) { | |
| 179 | 204 | cases := []struct { | |
| 180 | 205 | in string | |
| @@ -175,6 +175,31 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) { | |||||
| 175 | } | 175 | } | ||
| 176 | } | 176 | } | ||
| 177 | 177 | ||||
| 178 | + | func TestNormalizeSpawnReasoning(t *testing.T) { | |||
| 179 | + | cases := []struct { | |||
| 180 | + | in, want string | |||
| 181 | + | ok bool | |||
| 182 | + | }{ | |||
| 183 | + | {in: "OFF", want: "off", ok: true}, | |||
| 184 | + | {in: "minimal", want: "minimum", ok: true}, | |||
| 185 | + | {in: "maximum", want: "xhigh", ok: true}, | |||
| 186 | + | {in: "MAX", want: "max", ok: true}, | |||
| 187 | + | {in: "bogus", ok: false}, | |||
| 188 | + | } | |||
| 189 | + | for _, tc := range cases { | |||
| 190 | + | got, err := normalizeSpawnReasoning(tc.in) | |||
| 191 | + | if tc.ok { | |||
| 192 | + | if err != nil || got != tc.want { | |||
| 193 | + | t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want) | |||
| 194 | + | } | |||
| 195 | + | continue | |||
| 196 | + | } | |||
| 197 | + | if err == nil { | |||
| 198 | + | t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 178 | func TestParseSpawnFlags(t *testing.T) { | 203 | func TestParseSpawnFlags(t *testing.T) { | ||
| 179 | cases := []struct { | 204 | cases := []struct { | ||
| 180 | in string | 205 | in string | ||
M packages/agent/swarm/swarm.go +15 -7
| @@ -139,6 +139,15 @@ func New(cfg Config) *Swarm { | |||
| 139 | 139 | } | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | + | // SetRepoRoot updates the shared working directory for subsequently | |
| 143 | + | // spawned agents. Existing agents keep the directory they were started | |
| 144 | + | // with; callers use this when the host session changes cwd. | |
| 145 | + | func (f *Swarm) SetRepoRoot(root string) { | |
| 146 | + | f.mu.Lock() | |
| 147 | + | defer f.mu.Unlock() | |
| 148 | + | f.cfg.RepoRoot = root | |
| 149 | + | } | |
| 150 | + | ||
| 142 | 151 | // SetActiveSession scopes the dashboard view (and Spawn stamping) | |
| 143 | 152 | // to a particular host zot session id. Pass empty to clear the | |
| 144 | 153 | // scope and revert to "show every agent" (the original behaviour). | |
| @@ -213,7 +222,13 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||
| 213 | 222 | return nil, errors.New("swarm: empty task") | |
| 214 | 223 | } | |
| 215 | 224 | id := newAgentID(task, f.cfg.Now()) | |
| 225 | + | ||
| 226 | + | // Snapshot the shared cwd together with the active session so a | |
| 227 | + | // concurrent /cd cannot race a spawn or produce a mixed snapshot. | |
| 228 | + | f.mu.Lock() | |
| 216 | 229 | dir := f.cfg.RepoRoot | |
| 230 | + | sessionID := f.activeSession | |
| 231 | + | f.mu.Unlock() | |
| 217 | 232 | ||
| 218 | 233 | stateDir := f.agentStateDir(id) | |
| 219 | 234 | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |
| @@ -229,13 +244,6 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||
| 229 | 244 | return nil, fmt.Errorf("swarm inbox path: %w", err) | |
| 230 | 245 | } | |
| 231 | 246 | ||
| 232 | - | // Snapshot activeSession under the lock; we use it twice (struct | |
| 233 | - | // init below + persistence). Reading it without the lock would | |
| 234 | - | // race a concurrent SetActiveSession call. | |
| 235 | - | f.mu.Lock() | |
| 236 | - | sessionID := f.activeSession | |
| 237 | - | f.mu.Unlock() | |
| 238 | - | ||
| 239 | 247 | a := &Agent{ | |
| 240 | 248 | ID: id, | |
| 241 | 249 | Task: task, | |
| @@ -139,6 +139,15 @@ func New(cfg Config) *Swarm { | |||||
| 139 | } | 139 | } | ||
| 140 | } | 140 | } | ||
| 141 | 141 | ||||
| 142 | + | // SetRepoRoot updates the shared working directory for subsequently | |||
| 143 | + | // spawned agents. Existing agents keep the directory they were started | |||
| 144 | + | // with; callers use this when the host session changes cwd. | |||
| 145 | + | func (f *Swarm) SetRepoRoot(root string) { | |||
| 146 | + | f.mu.Lock() | |||
| 147 | + | defer f.mu.Unlock() | |||
| 148 | + | f.cfg.RepoRoot = root | |||
| 149 | + | } | |||
| 150 | + | ||||
| 142 | // SetActiveSession scopes the dashboard view (and Spawn stamping) | 151 | // SetActiveSession scopes the dashboard view (and Spawn stamping) | ||
| 143 | // to a particular host zot session id. Pass empty to clear the | 152 | // to a particular host zot session id. Pass empty to clear the | ||
| 144 | // scope and revert to "show every agent" (the original behaviour). | 153 | // scope and revert to "show every agent" (the original behaviour). | ||
| @@ -213,7 +222,13 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||||
| 213 | return nil, errors.New("swarm: empty task") | 222 | return nil, errors.New("swarm: empty task") | ||
| 214 | } | 223 | } | ||
| 215 | id := newAgentID(task, f.cfg.Now()) | 224 | id := newAgentID(task, f.cfg.Now()) | ||
| 225 | + | ||||
| 226 | + | // Snapshot the shared cwd together with the active session so a | |||
| 227 | + | // concurrent /cd cannot race a spawn or produce a mixed snapshot. | |||
| 228 | + | f.mu.Lock() | |||
| 216 | dir := f.cfg.RepoRoot | 229 | dir := f.cfg.RepoRoot | ||
| 230 | + | sessionID := f.activeSession | |||
| 231 | + | f.mu.Unlock() | |||
| 217 | 232 | ||||
| 218 | stateDir := f.agentStateDir(id) | 233 | stateDir := f.agentStateDir(id) | ||
| 219 | if err := os.MkdirAll(stateDir, 0o755); err != nil { | 234 | if err := os.MkdirAll(stateDir, 0o755); err != nil { | ||
| @@ -229,13 +244,6 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||||
| 229 | return nil, fmt.Errorf("swarm inbox path: %w", err) | 244 | return nil, fmt.Errorf("swarm inbox path: %w", err) | ||
| 230 | } | 245 | } | ||
| 231 | 246 | ||||
| 232 | - | // Snapshot activeSession under the lock; we use it twice (struct | |||
| 233 | - | // init below + persistence). Reading it without the lock would | |||
| 234 | - | // race a concurrent SetActiveSession call. | |||
| 235 | - | f.mu.Lock() | |||
| 236 | - | sessionID := f.activeSession | |||
| 237 | - | f.mu.Unlock() | |||
| 238 | - | ||||
| 239 | a := &Agent{ | 247 | a := &Agent{ | ||
| 240 | ID: id, | 248 | ID: id, | ||
| 241 | Task: task, | 249 | Task: task, | ||
M packages/agent/swarm/swarm_test.go +16 -0
| @@ -78,6 +78,22 @@ func TestSpawnAgentSharesRepoRoot(t *testing.T) { | |||
| 78 | 78 | } | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | + | func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) { | |
| 82 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 83 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |
| 84 | + | }) | |
| 85 | + | want := t.TempDir() | |
| 86 | + | f.SetRepoRoot(want) | |
| 87 | + | a, err := f.Spawn(context.Background(), "use new root") | |
| 88 | + | if err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | a.Wait() | |
| 92 | + | if a.Dir != want { | |
| 93 | + | t.Fatalf("Dir = %q; want updated RepoRoot %q", a.Dir, want) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 81 | 97 | func TestSpawnEmptyTaskFails(t *testing.T) { | |
| 82 | 98 | f := newTestSwarm(t, func(a *Agent) Runner { | |
| 83 | 99 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |
| @@ -78,6 +78,22 @@ func TestSpawnAgentSharesRepoRoot(t *testing.T) { | |||||
| 78 | } | 78 | } | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | + | func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) { | |||
| 82 | + | f := newTestSwarm(t, func(a *Agent) Runner { | |||
| 83 | + | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | |||
| 84 | + | }) | |||
| 85 | + | want := t.TempDir() | |||
| 86 | + | f.SetRepoRoot(want) | |||
| 87 | + | a, err := f.Spawn(context.Background(), "use new root") | |||
| 88 | + | if err != nil { | |||
| 89 | + | t.Fatal(err) | |||
| 90 | + | } | |||
| 91 | + | a.Wait() | |||
| 92 | + | if a.Dir != want { | |||
| 93 | + | t.Fatalf("Dir = %q; want updated RepoRoot %q", a.Dir, want) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 81 | func TestSpawnEmptyTaskFails(t *testing.T) { | 97 | func TestSpawnEmptyTaskFails(t *testing.T) { | ||
| 82 | f := newTestSwarm(t, func(a *Agent) Runner { | 98 | f := newTestSwarm(t, func(a *Agent) Runner { | ||
| 83 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | 99 | return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) | ||
M packages/agent/tools/swarm_spawn_test.go +32 -0
| @@ -104,6 +104,38 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) { | |||
| 104 | 104 | if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" { | |
| 105 | 105 | t.Fatalf("agent profile/reasoning = %#v", agents) | |
| 106 | 106 | } | |
| 107 | + | if agents[0].Model != "gpt-5.6-luna" || agents[0].Provider != "openai-codex" { | |
| 108 | + | t.Fatalf("agent model/provider = %q/%q, want gpt-5.6-luna/openai-codex", agents[0].Model, agents[0].Provider) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | |
| 113 | + | tool := &SwarmSpawnTool{ | |
| 114 | + | Swarm: newTestSwarm(t), | |
| 115 | + | Enabled: func() bool { return true }, | |
| 116 | + | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 117 | + | if name != "reviewer" { | |
| 118 | + | return nil, nil | |
| 119 | + | } | |
| 120 | + | return &subagents.Profile{ | |
| 121 | + | Name: "reviewer", | |
| 122 | + | Model: "openai-codex/gpt-5.6-luna", | |
| 123 | + | Thinking: "low", | |
| 124 | + | }, nil | |
| 125 | + | }, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil) | |
| 129 | + | if err != nil { | |
| 130 | + | t.Fatal(err) | |
| 131 | + | } | |
| 132 | + | if res.IsError { | |
| 133 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 134 | + | } | |
| 135 | + | agents := tool.Swarm.List() | |
| 136 | + | if len(agents) != 1 || agents[0].Reasoning != "low" { | |
| 137 | + | t.Fatalf("agent reasoning = %#v, want low", agents) | |
| 138 | + | } | |
| 107 | 139 | } | |
| 108 | 140 | ||
| 109 | 141 | func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) { | |
| @@ -104,6 +104,38 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) { | |||||
| 104 | if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" { | 104 | if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" { | ||
| 105 | t.Fatalf("agent profile/reasoning = %#v", agents) | 105 | t.Fatalf("agent profile/reasoning = %#v", agents) | ||
| 106 | } | 106 | } | ||
| 107 | + | if agents[0].Model != "gpt-5.6-luna" || agents[0].Provider != "openai-codex" { | |||
| 108 | + | t.Fatalf("agent model/provider = %q/%q, want gpt-5.6-luna/openai-codex", agents[0].Model, agents[0].Provider) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | |||
| 113 | + | tool := &SwarmSpawnTool{ | |||
| 114 | + | Swarm: newTestSwarm(t), | |||
| 115 | + | Enabled: func() bool { return true }, | |||
| 116 | + | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |||
| 117 | + | if name != "reviewer" { | |||
| 118 | + | return nil, nil | |||
| 119 | + | } | |||
| 120 | + | return &subagents.Profile{ | |||
| 121 | + | Name: "reviewer", | |||
| 122 | + | Model: "openai-codex/gpt-5.6-luna", | |||
| 123 | + | Thinking: "low", | |||
| 124 | + | }, nil | |||
| 125 | + | }, | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil) | |||
| 129 | + | if err != nil { | |||
| 130 | + | t.Fatal(err) | |||
| 131 | + | } | |||
| 132 | + | if res.IsError { | |||
| 133 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |||
| 134 | + | } | |||
| 135 | + | agents := tool.Swarm.List() | |||
| 136 | + | if len(agents) != 1 || agents[0].Reasoning != "low" { | |||
| 137 | + | t.Fatalf("agent reasoning = %#v, want low", agents) | |||
| 138 | + | } | |||
| 107 | } | 139 | } | ||
| 108 | 140 | ||||
| 109 | func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) { | 141 | func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) { | ||