detached 4bec50a

fix(swarm): inherit host model provider for auto-spawn

commit
4bec50ae9c11ce1f5a7b38a1defd757b2a857671
author
patriceckhart · · 2026-06-22 15:39 UTC
parent
6899695
signature
unsigned
refs
tag: v0.2.44
4 files · +153 -19
M packages/agent/cli.go +5 -3
@@ -484,9 +484,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
484484 return reg
485485 }
486486 reg["swarm_spawn"] = &tools.SwarmSpawnTool{
487- Swarm: swarmMgr,
488- Enabled: AutoSwarmEnabled,
489- OnSpawned: onSpawnedSwarm,
487+ Swarm: swarmMgr,
488+ Enabled: AutoSwarmEnabled,
489+ DefaultModel: func() string { return r.Model },
490+ DefaultProvider: func() string { return r.Provider },
491+ OnSpawned: onSpawnedSwarm,
490492 }
491493 return reg
492494 }
@@ -484,9 +484,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
484 return reg484 return reg
485 }485 }
486 reg["swarm_spawn"] = &tools.SwarmSpawnTool{486 reg["swarm_spawn"] = &tools.SwarmSpawnTool{
487- Swarm: swarmMgr,487+ Swarm: swarmMgr,
488- Enabled: AutoSwarmEnabled,488+ Enabled: AutoSwarmEnabled,
489- OnSpawned: onSpawnedSwarm,489+ DefaultModel: func() string { return r.Model },
490+ DefaultProvider: func() string { return r.Provider },
491+ OnSpawned: onSpawnedSwarm,
490 }492 }
491 return reg493 return reg
492 }494 }
M packages/agent/modes/interactive.go +17 -6
@@ -508,6 +508,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
508508 i.view.TailLimit = initialResumeTailLimit
509509 }
510510 }
511+ if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
512+ i.applyAutoSwarmTool(true)
513+ }
511514 return i
512515 }
513516
@@ -4175,9 +4178,10 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
41754178 // to invalidate.
41764179 i.mu.Unlock()
41774180 // The new agent was built off the base tool registry, so any
4178- // dynamically-registered tools (telegram_send_*) need to be
4179- // reattached. applyTelegramTools is a no-op when the bridge is
4180- // idle so the cross-provider path still works on a vanilla setup.
4181+ // dynamically-registered tools need to be reattached. The apply
4182+ // helpers are no-ops when their feature is inactive, so the
4183+ // cross-provider path still works on a vanilla setup.
4184+ i.applyAutoSwarmTool(i.autoSwarmEnabled())
41814185 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
41824186 if i.cfg.PersistModel != nil {
41834187 i.cfg.PersistModel(p, md)
@@ -4206,6 +4210,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
42064210 i.statusErr = ""
42074211 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
42084212 i.mu.Unlock()
4213+ i.applyAutoSwarmTool(i.autoSwarmEnabled())
42094214 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
42104215 i.dialog.ShowResult(true, "")
42114216 }
@@ -5269,6 +5274,10 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
52695274 // when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
52705275 // snapshot+mutate pattern so extension tools and /reload-ext additions
52715276 // survive a toggle.
5277+func (i *Interactive) autoSwarmEnabled() bool {
5278+ return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
5279+}
5280+
52725281 func (i *Interactive) applyAutoSwarmTool(active bool) {
52735282 if i.agent == nil {
52745283 return
@@ -5283,9 +5292,11 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
52835292 }
52845293 if active && i.cfg.Swarm != nil {
52855294 next["swarm_spawn"] = &tools.SwarmSpawnTool{
5286- Swarm: i.cfg.Swarm,
5287- Enabled: func() bool { return true },
5288- OnSpawned: i.trackSwarmAgent,
5295+ Swarm: i.cfg.Swarm,
5296+ Enabled: func() bool { return true },
5297+ DefaultModel: func() string { return i.cfg.Model },
5298+ DefaultProvider: func() string { return i.cfg.Provider },
5299+ OnSpawned: i.trackSwarmAgent,
52895300 }
52905301 }
52915302 i.agent.SetTools(next)
@@ -508,6 +508,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
508 i.view.TailLimit = initialResumeTailLimit508 i.view.TailLimit = initialResumeTailLimit
509 }509 }
510 }510 }
511+ if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
512+ i.applyAutoSwarmTool(true)
513+ }
511 return i514 return i
512 }515 }
513 516
@@ -4175,9 +4178,10 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
4175 // to invalidate.4178 // to invalidate.
4176 i.mu.Unlock()4179 i.mu.Unlock()
4177 // The new agent was built off the base tool registry, so any4180 // The new agent was built off the base tool registry, so any
4178- // dynamically-registered tools (telegram_send_*) need to be4181+ // dynamically-registered tools need to be reattached. The apply
4179- // reattached. applyTelegramTools is a no-op when the bridge is4182+ // helpers are no-ops when their feature is inactive, so the
4180- // idle so the cross-provider path still works on a vanilla setup.4183+ // cross-provider path still works on a vanilla setup.
4184+ i.applyAutoSwarmTool(i.autoSwarmEnabled())
4181 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())4185 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
4182 if i.cfg.PersistModel != nil {4186 if i.cfg.PersistModel != nil {
4183 i.cfg.PersistModel(p, md)4187 i.cfg.PersistModel(p, md)
@@ -4206,6 +4210,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
4206 i.statusErr = ""4210 i.statusErr = ""
4207 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method4211 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
4208 i.mu.Unlock()4212 i.mu.Unlock()
4213+ i.applyAutoSwarmTool(i.autoSwarmEnabled())
4209 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())4214 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
4210 i.dialog.ShowResult(true, "")4215 i.dialog.ShowResult(true, "")
4211 }4216 }
@@ -5269,6 +5274,10 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
5269 // when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'5274 // when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
5270 // snapshot+mutate pattern so extension tools and /reload-ext additions5275 // snapshot+mutate pattern so extension tools and /reload-ext additions
5271 // survive a toggle.5276 // survive a toggle.
5277+func (i *Interactive) autoSwarmEnabled() bool {
5278+ return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
5279+}
5280+
5272 func (i *Interactive) applyAutoSwarmTool(active bool) {5281 func (i *Interactive) applyAutoSwarmTool(active bool) {
5273 if i.agent == nil {5282 if i.agent == nil {
5274 return5283 return
@@ -5283,9 +5292,11 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
5283 }5292 }
5284 if active && i.cfg.Swarm != nil {5293 if active && i.cfg.Swarm != nil {
5285 next["swarm_spawn"] = &tools.SwarmSpawnTool{5294 next["swarm_spawn"] = &tools.SwarmSpawnTool{
5286- Swarm: i.cfg.Swarm,5295+ Swarm: i.cfg.Swarm,
5287- Enabled: func() bool { return true },5296+ Enabled: func() bool { return true },
5288- OnSpawned: i.trackSwarmAgent,5297+ DefaultModel: func() string { return i.cfg.Model },
5298+ DefaultProvider: func() string { return i.cfg.Provider },
5299+ OnSpawned: i.trackSwarmAgent,
5289 }5300 }
5290 }5301 }
5291 i.agent.SetTools(next)5302 i.agent.SetTools(next)
M packages/agent/tools/swarm_spawn.go +31 -10
@@ -31,6 +31,13 @@ type SwarmSpawnTool struct {
3131 // is treated as disabled.
3232 Enabled func() bool
3333
34+ // DefaultModel and DefaultProvider return the host agent's resolved
35+ // model and provider. They are used when the tool call omits both
36+ // fields, so auto-swarm follows the same auth route as the user sees
37+ // in the parent session.
38+ DefaultModel func() string
39+ DefaultProvider func() string
40+
3441 // OnSpawned, if set, is called after every successful spawn with
3542 // the new agent + the task it was started with. Used by the
3643 // interactive host to track agents and surface a summary back
@@ -53,11 +60,11 @@ const swarmSpawnSchema = `{
5360 },
5461 "model": {
5562 "type": "string",
56- "description": "Optional model id to pin the sub-agent to (e.g. \"claude-sonnet-4-5\", \"gpt-5\"). Defaults to the host's current model."
63+ "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route. Do not infer provider from model name. If you override this, also provide provider."
5764 },
5865 "provider": {
5966 "type": "string",
60- "description": "Optional provider id (e.g. \"anthropic\", \"openai\"). Usually paired with model."
67+ "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth."
6168 }
6269 },
6370 "required": ["task"]
@@ -85,10 +92,24 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
8592 return toolErr("swarm_spawn: task is required"), nil
8693 }
8794
95+ model := strings.TrimSpace(a.Model)
96+ providerID := strings.TrimSpace(a.Provider)
97+ if (model == "") != (providerID == "") {
98+ return toolErr("swarm_spawn: omit both model/provider to inherit the host, or provide both explicitly"), nil
99+ }
100+ if model == "" && providerID == "" {
101+ if t.DefaultModel != nil {
102+ model = strings.TrimSpace(t.DefaultModel())
103+ }
104+ if t.DefaultProvider != nil {
105+ providerID = strings.TrimSpace(t.DefaultProvider())
106+ }
107+ }
108+
88109 agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
89110 Task: task,
90- Model: strings.TrimSpace(a.Model),
91- Provider: strings.TrimSpace(a.Provider),
111+ Model: model,
112+ Provider: providerID,
92113 })
93114 if err != nil {
94115 return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)
@@ -100,11 +121,11 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
100121 var sb strings.Builder
101122 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
102123 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
103- if a.Model != "" {
104- fmt.Fprintf(&sb, "model: %s\n", a.Model)
124+ if model != "" {
125+ fmt.Fprintf(&sb, "model: %s\n", model)
105126 }
106- if a.Provider != "" {
107- fmt.Fprintf(&sb, "provider: %s\n", a.Provider)
127+ if providerID != "" {
128+ fmt.Fprintf(&sb, "provider: %s\n", providerID)
108129 }
109130 sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ")
110131 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
@@ -113,8 +134,8 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
113134 Details: map[string]any{
114135 "agent_id": agent.ID,
115136 "task": task,
116- "model": a.Model,
117- "provider": a.Provider,
137+ "model": model,
138+ "provider": providerID,
118139 },
119140 }, nil
120141 }
@@ -31,6 +31,13 @@ type SwarmSpawnTool struct {
31 // is treated as disabled.31 // is treated as disabled.
32 Enabled func() bool32 Enabled func() bool
33 33
34+ // DefaultModel and DefaultProvider return the host agent's resolved
35+ // model and provider. They are used when the tool call omits both
36+ // fields, so auto-swarm follows the same auth route as the user sees
37+ // in the parent session.
38+ DefaultModel func() string
39+ DefaultProvider func() string
40+
34 // OnSpawned, if set, is called after every successful spawn with41 // OnSpawned, if set, is called after every successful spawn with
35 // the new agent + the task it was started with. Used by the42 // the new agent + the task it was started with. Used by the
36 // interactive host to track agents and surface a summary back43 // interactive host to track agents and surface a summary back
@@ -53,11 +60,11 @@ const swarmSpawnSchema = `{
53 },60 },
54 "model": {61 "model": {
55 "type": "string",62 "type": "string",
56- "description": "Optional model id to pin the sub-agent to (e.g. \"claude-sonnet-4-5\", \"gpt-5\"). Defaults to the host's current model."63+ "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route. Do not infer provider from model name. If you override this, also provide provider."
57 },64 },
58 "provider": {65 "provider": {
59 "type": "string",66 "type": "string",
60- "description": "Optional provider id (e.g. \"anthropic\", \"openai\"). Usually paired with model."67+ "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth."
61 }68 }
62 },69 },
63 "required": ["task"]70 "required": ["task"]
@@ -85,10 +92,24 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
85 return toolErr("swarm_spawn: task is required"), nil92 return toolErr("swarm_spawn: task is required"), nil
86 }93 }
87 94
95+ model := strings.TrimSpace(a.Model)
96+ providerID := strings.TrimSpace(a.Provider)
97+ if (model == "") != (providerID == "") {
98+ return toolErr("swarm_spawn: omit both model/provider to inherit the host, or provide both explicitly"), nil
99+ }
100+ if model == "" && providerID == "" {
101+ if t.DefaultModel != nil {
102+ model = strings.TrimSpace(t.DefaultModel())
103+ }
104+ if t.DefaultProvider != nil {
105+ providerID = strings.TrimSpace(t.DefaultProvider())
106+ }
107+ }
108+
88 agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{109 agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
89 Task: task,110 Task: task,
90- Model: strings.TrimSpace(a.Model),111+ Model: model,
91- Provider: strings.TrimSpace(a.Provider),112+ Provider: providerID,
92 })113 })
93 if err != nil {114 if err != nil {
94 return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)115 return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)
@@ -100,11 +121,11 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
100 var sb strings.Builder121 var sb strings.Builder
101 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)122 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
102 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))123 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
103- if a.Model != "" {124+ if model != "" {
104- fmt.Fprintf(&sb, "model: %s\n", a.Model)125+ fmt.Fprintf(&sb, "model: %s\n", model)
105 }126 }
106- if a.Provider != "" {127+ if providerID != "" {
107- fmt.Fprintf(&sb, "provider: %s\n", a.Provider)128+ fmt.Fprintf(&sb, "provider: %s\n", providerID)
108 }129 }
109 sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ")130 sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ")
110 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")131 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
@@ -113,8 +134,8 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
113 Details: map[string]any{134 Details: map[string]any{
114 "agent_id": agent.ID,135 "agent_id": agent.ID,
115 "task": task,136 "task": task,
116- "model": a.Model,137+ "model": model,
117- "provider": a.Provider,138+ "provider": providerID,
118 },139 },
119 }, nil140 }, nil
120 }141 }
A packages/agent/tools/swarm_spawn_test.go +100 -0
@@ -0,0 +1,100 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/patriceckhart/zot/packages/agent/swarm"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+type noopSwarmRunner struct{}
15+
16+func (noopSwarmRunner) Run(context.Context, swarm.Sink) error { return nil }
17+
18+func newTestSwarm(t *testing.T) *swarm.Swarm {
19+ t.Helper()
20+ root := t.TempDir()
21+ return swarm.New(swarm.Config{
22+ Root: filepath.Join(root, "swarm"),
23+ RepoRoot: root,
24+ NewRunner: func(*swarm.Agent) swarm.Runner {
25+ return noopSwarmRunner{}
26+ },
27+ })
28+}
29+
30+func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
31+ tool := &SwarmSpawnTool{
32+ Swarm: newTestSwarm(t),
33+ Enabled: func() bool { return true },
34+ DefaultModel: func() string { return "gpt-5" },
35+ DefaultProvider: func() string { return "openai-codex" },
36+ }
37+
38+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if res.IsError {
43+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
44+ }
45+ details, ok := res.Details.(map[string]any)
46+ if !ok {
47+ t.Fatalf("details type = %T, want map[string]any", res.Details)
48+ }
49+ if got := details["model"]; got != "gpt-5" {
50+ t.Fatalf("model detail = %v, want gpt-5", got)
51+ }
52+ if got := details["provider"]; got != "openai-codex" {
53+ t.Fatalf("provider detail = %v, want openai-codex", got)
54+ }
55+ text := textResult(res.Content)
56+ if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") {
57+ t.Fatalf("result text missing inherited model/provider:\n%s", text)
58+ }
59+
60+ agents := tool.Swarm.List()
61+ if len(agents) != 1 {
62+ t.Fatalf("spawned agents = %d, want 1", len(agents))
63+ }
64+ if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" {
65+ t.Fatalf("agent model/provider = %q/%q, want gpt-5/openai-codex", agents[0].Model, agents[0].Provider)
66+ }
67+}
68+
69+func TestSwarmSpawnRejectsPartialModelProviderOverride(t *testing.T) {
70+ tool := &SwarmSpawnTool{
71+ Swarm: newTestSwarm(t),
72+ Enabled: func() bool { return true },
73+ DefaultModel: func() string { return "gpt-5" },
74+ DefaultProvider: func() string { return "openai-codex" },
75+ }
76+
77+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","provider":"openai"}`), nil)
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ if !res.IsError {
82+ t.Fatalf("expected partial override to fail")
83+ }
84+ if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") {
85+ t.Fatalf("error text = %q", got)
86+ }
87+ if got := len(tool.Swarm.List()); got != 0 {
88+ t.Fatalf("spawned agents = %d, want 0", got)
89+ }
90+}
91+
92+func textResult(content []provider.Content) string {
93+ if len(content) == 0 {
94+ return ""
95+ }
96+ if tb, ok := content[0].(provider.TextBlock); ok {
97+ return tb.Text
98+ }
99+ return ""
100+}
@@ -0,0 +1,100 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/patriceckhart/zot/packages/agent/swarm"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+type noopSwarmRunner struct{}
15+
16+func (noopSwarmRunner) Run(context.Context, swarm.Sink) error { return nil }
17+
18+func newTestSwarm(t *testing.T) *swarm.Swarm {
19+ t.Helper()
20+ root := t.TempDir()
21+ return swarm.New(swarm.Config{
22+ Root: filepath.Join(root, "swarm"),
23+ RepoRoot: root,
24+ NewRunner: func(*swarm.Agent) swarm.Runner {
25+ return noopSwarmRunner{}
26+ },
27+ })
28+}
29+
30+func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
31+ tool := &SwarmSpawnTool{
32+ Swarm: newTestSwarm(t),
33+ Enabled: func() bool { return true },
34+ DefaultModel: func() string { return "gpt-5" },
35+ DefaultProvider: func() string { return "openai-codex" },
36+ }
37+
38+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if res.IsError {
43+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
44+ }
45+ details, ok := res.Details.(map[string]any)
46+ if !ok {
47+ t.Fatalf("details type = %T, want map[string]any", res.Details)
48+ }
49+ if got := details["model"]; got != "gpt-5" {
50+ t.Fatalf("model detail = %v, want gpt-5", got)
51+ }
52+ if got := details["provider"]; got != "openai-codex" {
53+ t.Fatalf("provider detail = %v, want openai-codex", got)
54+ }
55+ text := textResult(res.Content)
56+ if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") {
57+ t.Fatalf("result text missing inherited model/provider:\n%s", text)
58+ }
59+
60+ agents := tool.Swarm.List()
61+ if len(agents) != 1 {
62+ t.Fatalf("spawned agents = %d, want 1", len(agents))
63+ }
64+ if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" {
65+ t.Fatalf("agent model/provider = %q/%q, want gpt-5/openai-codex", agents[0].Model, agents[0].Provider)
66+ }
67+}
68+
69+func TestSwarmSpawnRejectsPartialModelProviderOverride(t *testing.T) {
70+ tool := &SwarmSpawnTool{
71+ Swarm: newTestSwarm(t),
72+ Enabled: func() bool { return true },
73+ DefaultModel: func() string { return "gpt-5" },
74+ DefaultProvider: func() string { return "openai-codex" },
75+ }
76+
77+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","provider":"openai"}`), nil)
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ if !res.IsError {
82+ t.Fatalf("expected partial override to fail")
83+ }
84+ if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") {
85+ t.Fatalf("error text = %q", got)
86+ }
87+ if got := len(tool.Swarm.List()); got != 0 {
88+ t.Fatalf("spawned agents = %d, want 0", got)
89+ }
90+}
91+
92+func textResult(content []provider.Content) string {
93+ if len(content) == 0 {
94+ return ""
95+ }
96+ if tb, ok := content[0].(provider.TextBlock); ok {
97+ return tb.Text
98+ }
99+ return ""
100+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close