detached 86d3d2d

fix(agent): preserve prompt ownership and profile validation

commit
86d3d2d42b283bc0c5374f189c183076ba3d8d8a
author
brice · · 2026-08-04 04:54 UTC
parent
2830302
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +110 -34
M packages/agent/build_test.go +2 -0
@@ -91,6 +91,7 @@ func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) {
9191 }
9292 t.Setenv("HOME", home)
9393 t.Setenv("ZOT_HOME", zotHome)
94+ t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
9495 enabled := true
9596 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil {
9697 t.Fatal(err)
@@ -130,6 +131,7 @@ func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
130131 }
131132 t.Setenv("HOME", home)
132133 t.Setenv("ZOT_HOME", zotHome)
134+ t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
133135 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil {
134136 t.Fatal(err)
135137 }
@@ -91,6 +91,7 @@ func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) {
91 }91 }
92 t.Setenv("HOME", home)92 t.Setenv("HOME", home)
93 t.Setenv("ZOT_HOME", zotHome)93 t.Setenv("ZOT_HOME", zotHome)
94+ t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
94 enabled := true95 enabled := true
95 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil {96 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil {
96 t.Fatal(err)97 t.Fatal(err)
@@ -130,6 +131,7 @@ func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
130 }131 }
131 t.Setenv("HOME", home)132 t.Setenv("HOME", home)
132 t.Setenv("ZOT_HOME", zotHome)133 t.Setenv("ZOT_HOME", zotHome)
134+ t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
133 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil {135 if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil {
134 t.Fatal(err)136 t.Fatal(err)
135 }137 }
M packages/agent/cli.go +2 -2
@@ -1153,7 +1153,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11531153 newAg, newProvider, newModel, berr := buildAgent()
11541154 if berr != nil {
11551155 rollback()
1156- return fmt.Errorf("rebuild agent: %v", berr)
1156+ return fmt.Errorf("rebuild agent: %w", berr)
11571157 }
11581158
11591159 // Fresh session in the new cwd's bucket. We bypass
@@ -1168,7 +1168,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11681168 newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
11691169 if serr != nil {
11701170 rollback()
1171- return fmt.Errorf("open session in %s: %v", absPath, serr)
1171+ return fmt.Errorf("open session in %s: %w", absPath, serr)
11721172 }
11731173 }
11741174
@@ -1153,7 +1153,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1153 newAg, newProvider, newModel, berr := buildAgent()1153 newAg, newProvider, newModel, berr := buildAgent()
1154 if berr != nil {1154 if berr != nil {
1155 rollback()1155 rollback()
1156- return fmt.Errorf("rebuild agent: %v", berr)1156+ return fmt.Errorf("rebuild agent: %w", berr)
1157 }1157 }
1158 1158
1159 // Fresh session in the new cwd's bucket. We bypass1159 // Fresh session in the new cwd's bucket. We bypass
@@ -1168,7 +1168,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1168 newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)1168 newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
1169 if serr != nil {1169 if serr != nil {
1170 rollback()1170 rollback()
1171- return fmt.Errorf("open session in %s: %v", absPath, serr)1171+ return fmt.Errorf("open session in %s: %w", absPath, serr)
1172 }1172 }
1173 }1173 }
1174 1174
M packages/agent/modes/auto_swarm_test.go +22 -0
@@ -36,6 +36,28 @@ func TestAutoSwarmSystemPromptTogglesProfileManifestWithDelegationGuidance(t *te
3636 }
3737 }
3838
39+func TestAutoSwarmTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
40+ manifest := "[subagents_list]\n- reviewer\n[/subagents_list]"
41+ guidance := "auto-swarm guidance"
42+ base := "base system\n\n" + manifest + "\n\n" + guidance
43+ iv := &Interactive{
44+ agent: &core.Agent{System: base},
45+ cfg: InteractiveConfig{
46+ AutoSwarmSystemAddendum: guidance,
47+ SubagentsSystemAddendum: manifest,
48+ },
49+ }
50+
51+ iv.applyAutoSwarmSystemPrompt(true)
52+ if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 {
53+ t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System)
54+ }
55+ iv.applyAutoSwarmSystemPrompt(false)
56+ if got := strings.TrimSpace(iv.agent.System); got != base {
57+ t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base)
58+ }
59+}
60+
3961 func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {
4062 response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"
4163 mgr := swarm.New(swarm.Config{
@@ -36,6 +36,28 @@ func TestAutoSwarmSystemPromptTogglesProfileManifestWithDelegationGuidance(t *te
36 }36 }
37 }37 }
38 38
39+func TestAutoSwarmTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
40+ manifest := "[subagents_list]\n- reviewer\n[/subagents_list]"
41+ guidance := "auto-swarm guidance"
42+ base := "base system\n\n" + manifest + "\n\n" + guidance
43+ iv := &Interactive{
44+ agent: &core.Agent{System: base},
45+ cfg: InteractiveConfig{
46+ AutoSwarmSystemAddendum: guidance,
47+ SubagentsSystemAddendum: manifest,
48+ },
49+ }
50+
51+ iv.applyAutoSwarmSystemPrompt(true)
52+ if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 {
53+ t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System)
54+ }
55+ iv.applyAutoSwarmSystemPrompt(false)
56+ if got := strings.TrimSpace(iv.agent.System); got != base {
57+ t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base)
58+ }
59+}
60+
39 func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {61 func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {
40 response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"62 response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"
41 mgr := swarm.New(swarm.Config{63 mgr := swarm.New(swarm.Config{
M packages/agent/modes/interactive.go +59 -22
@@ -368,10 +368,14 @@ type Interactive struct {
368368 ed *tui.Editor
369369 rend *tui.Renderer
370370
371- mu sync.Mutex
372- agent *core.Agent
373- streaming strings.Builder // what's currently painted on screen
374- streamOn bool
371+ mu sync.Mutex
372+ agent *core.Agent
373+ // managedAutoSwarmAddenda records exact prompt blocks appended by
374+ // auto-swarm. Disable only removes these owned occurrences, leaving
375+ // identical text that came from the user's base prompt untouched.
376+ managedAutoSwarmAddenda []string
377+ streaming strings.Builder // what's currently painted on screen
378+ streamOn bool
375379
376380 // streamPending is the runes buffered after each EvTextDelta that
377381 // haven't yet been promoted into `streaming` for rendering. It
@@ -666,6 +670,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
666670 baseURL, _, err := cfg.LlamaCPPConfig()
667671 i.llamaConfigured = err == nil && baseURL != ""
668672 }
673+ if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
674+ i.managedAutoSwarmAddenda = autoSwarmAddenda(cfg)
675+ }
669676 if cfg.Agent != nil {
670677 i.agent = cfg.Agent
671678 i.view.Messages = cfg.Agent.Messages()
@@ -3070,6 +3077,11 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
30703077 i.agent = ag
30713078 i.cfg.CWD = cwd
30723079 i.cfg.SubagentsSystemAddendum = subagentsAddendum
3080+ if i.autoSwarmEnabled() {
3081+ i.managedAutoSwarmAddenda = autoSwarmAddenda(i.cfg)
3082+ } else {
3083+ i.managedAutoSwarmAddenda = nil
3084+ }
30733085 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
30743086 i.view.StartupContextPaths = nil
30753087 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
@@ -6501,6 +6513,39 @@ func truncateForSummary(s string, n int) string {
65016513 return s[:n-3] + "..."
65026514 }
65036515
6516+// autoSwarmAddenda returns the prompt blocks owned by the auto-swarm
6517+// toggle, in the same order Resolve appends them to a new agent.
6518+func autoSwarmAddenda(cfg InteractiveConfig) []string {
6519+ addenda := make([]string, 0, 2)
6520+ if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
6521+ addenda = append(addenda, addendum)
6522+ }
6523+ if addendum := strings.TrimSpace(cfg.AutoSwarmSystemAddendum); addendum != "" {
6524+ addenda = append(addenda, addendum)
6525+ }
6526+ return addenda
6527+}
6528+
6529+func containsAutoSwarmAddendum(addenda []string, want string) bool {
6530+ for _, addendum := range addenda {
6531+ if addendum == want {
6532+ return true
6533+ }
6534+ }
6535+ return false
6536+}
6537+
6538+// removeLastAutoSwarmAddendum removes one occurrence of a block known to
6539+// have been appended by auto-swarm. Resolve appends owned blocks at the
6540+// end, so removing the last occurrence preserves an identical base block.
6541+func removeLastAutoSwarmAddendum(system, addendum string) (string, bool) {
6542+ idx := strings.LastIndex(system, addendum)
6543+ if idx < 0 {
6544+ return system, false
6545+ }
6546+ return system[:idx] + system[idx+len(addendum):], true
6547+}
6548+
65046549 // applyAutoSwarmSystemPrompt appends (active=true) or strips
65056550 // (active=false) the auto-swarm prompt blocks on the running agent.
65066551 // The profile manifest and delegation guidance are managed together so
@@ -6509,39 +6554,31 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
65096554 if i.agent == nil {
65106555 return
65116556 }
6512- addenda := make([]string, 0, 2)
6513- if addendum := strings.TrimSpace(i.cfg.SubagentsSystemAddendum); addendum != "" {
6514- addenda = append(addenda, addendum)
6515- }
6516- if addendum := strings.TrimSpace(i.cfg.AutoSwarmSystemAddendum); addendum != "" {
6517- addenda = append(addenda, addendum)
6518- }
6519- if len(addenda) == 0 {
6520- return
6521- }
6522-
6523- sys := i.agent.System
6557+ addenda := autoSwarmAddenda(i.cfg)
65246558 if active {
6559+ sys := i.agent.System
65256560 for _, addendum := range addenda {
6526- if strings.Contains(sys, addendum) {
6561+ if containsAutoSwarmAddendum(i.managedAutoSwarmAddenda, addendum) {
65276562 continue
65286563 }
65296564 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
65306565 sys += "\n\n"
65316566 }
65326567 sys += addendum
6568+ i.managedAutoSwarmAddenda = append(i.managedAutoSwarmAddenda, addendum)
65336569 }
65346570 i.agent.System = sys
65356571 return
65366572 }
65376573
6574+ sys := i.agent.System
65386575 changed := false
6539- for _, addendum := range addenda {
6540- if strings.Contains(sys, addendum) {
6541- changed = true
6542- sys = strings.ReplaceAll(sys, addendum, "")
6543- }
6576+ for idx := len(i.managedAutoSwarmAddenda) - 1; idx >= 0; idx-- {
6577+ var removed bool
6578+ sys, removed = removeLastAutoSwarmAddendum(sys, i.managedAutoSwarmAddenda[idx])
6579+ changed = changed || removed
65446580 }
6581+ i.managedAutoSwarmAddenda = nil
65456582 if changed {
65466583 i.agent.System = strings.TrimRight(sys, "\n") + "\n"
65476584 }
@@ -368,10 +368,14 @@ type Interactive struct {
368 ed *tui.Editor368 ed *tui.Editor
369 rend *tui.Renderer369 rend *tui.Renderer
370 370
371- mu sync.Mutex371+ mu sync.Mutex
372- agent *core.Agent372+ agent *core.Agent
373- streaming strings.Builder // what's currently painted on screen373+ // managedAutoSwarmAddenda records exact prompt blocks appended by
374- streamOn bool374+ // auto-swarm. Disable only removes these owned occurrences, leaving
375+ // identical text that came from the user's base prompt untouched.
376+ managedAutoSwarmAddenda []string
377+ streaming strings.Builder // what's currently painted on screen
378+ streamOn bool
375 379
376 // streamPending is the runes buffered after each EvTextDelta that380 // streamPending is the runes buffered after each EvTextDelta that
377 // haven't yet been promoted into `streaming` for rendering. It381 // haven't yet been promoted into `streaming` for rendering. It
@@ -666,6 +670,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
666 baseURL, _, err := cfg.LlamaCPPConfig()670 baseURL, _, err := cfg.LlamaCPPConfig()
667 i.llamaConfigured = err == nil && baseURL != ""671 i.llamaConfigured = err == nil && baseURL != ""
668 }672 }
673+ if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
674+ i.managedAutoSwarmAddenda = autoSwarmAddenda(cfg)
675+ }
669 if cfg.Agent != nil {676 if cfg.Agent != nil {
670 i.agent = cfg.Agent677 i.agent = cfg.Agent
671 i.view.Messages = cfg.Agent.Messages()678 i.view.Messages = cfg.Agent.Messages()
@@ -3070,6 +3077,11 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
3070 i.agent = ag3077 i.agent = ag
3071 i.cfg.CWD = cwd3078 i.cfg.CWD = cwd
3072 i.cfg.SubagentsSystemAddendum = subagentsAddendum3079 i.cfg.SubagentsSystemAddendum = subagentsAddendum
3080+ if i.autoSwarmEnabled() {
3081+ i.managedAutoSwarmAddenda = autoSwarmAddenda(i.cfg)
3082+ } else {
3083+ i.managedAutoSwarmAddenda = nil
3084+ }
3073 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)3085 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
3074 i.view.StartupContextPaths = nil3086 i.view.StartupContextPaths = nil
3075 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {3087 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
@@ -6501,6 +6513,39 @@ func truncateForSummary(s string, n int) string {
6501 return s[:n-3] + "..."6513 return s[:n-3] + "..."
6502 }6514 }
6503 6515
6516+// autoSwarmAddenda returns the prompt blocks owned by the auto-swarm
6517+// toggle, in the same order Resolve appends them to a new agent.
6518+func autoSwarmAddenda(cfg InteractiveConfig) []string {
6519+ addenda := make([]string, 0, 2)
6520+ if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
6521+ addenda = append(addenda, addendum)
6522+ }
6523+ if addendum := strings.TrimSpace(cfg.AutoSwarmSystemAddendum); addendum != "" {
6524+ addenda = append(addenda, addendum)
6525+ }
6526+ return addenda
6527+}
6528+
6529+func containsAutoSwarmAddendum(addenda []string, want string) bool {
6530+ for _, addendum := range addenda {
6531+ if addendum == want {
6532+ return true
6533+ }
6534+ }
6535+ return false
6536+}
6537+
6538+// removeLastAutoSwarmAddendum removes one occurrence of a block known to
6539+// have been appended by auto-swarm. Resolve appends owned blocks at the
6540+// end, so removing the last occurrence preserves an identical base block.
6541+func removeLastAutoSwarmAddendum(system, addendum string) (string, bool) {
6542+ idx := strings.LastIndex(system, addendum)
6543+ if idx < 0 {
6544+ return system, false
6545+ }
6546+ return system[:idx] + system[idx+len(addendum):], true
6547+}
6548+
6504 // applyAutoSwarmSystemPrompt appends (active=true) or strips6549 // applyAutoSwarmSystemPrompt appends (active=true) or strips
6505 // (active=false) the auto-swarm prompt blocks on the running agent.6550 // (active=false) the auto-swarm prompt blocks on the running agent.
6506 // The profile manifest and delegation guidance are managed together so6551 // The profile manifest and delegation guidance are managed together so
@@ -6509,39 +6554,31 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
6509 if i.agent == nil {6554 if i.agent == nil {
6510 return6555 return
6511 }6556 }
6512- addenda := make([]string, 0, 2)6557+ addenda := autoSwarmAddenda(i.cfg)
6513- if addendum := strings.TrimSpace(i.cfg.SubagentsSystemAddendum); addendum != "" {
6514- addenda = append(addenda, addendum)
6515- }
6516- if addendum := strings.TrimSpace(i.cfg.AutoSwarmSystemAddendum); addendum != "" {
6517- addenda = append(addenda, addendum)
6518- }
6519- if len(addenda) == 0 {
6520- return
6521- }
6522-
6523- sys := i.agent.System
6524 if active {6558 if active {
6559+ sys := i.agent.System
6525 for _, addendum := range addenda {6560 for _, addendum := range addenda {
6526- if strings.Contains(sys, addendum) {6561+ if containsAutoSwarmAddendum(i.managedAutoSwarmAddenda, addendum) {
6527 continue6562 continue
6528 }6563 }
6529 if sys != "" && !strings.HasSuffix(sys, "\n\n") {6564 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
6530 sys += "\n\n"6565 sys += "\n\n"
6531 }6566 }
6532 sys += addendum6567 sys += addendum
6568+ i.managedAutoSwarmAddenda = append(i.managedAutoSwarmAddenda, addendum)
6533 }6569 }
6534 i.agent.System = sys6570 i.agent.System = sys
6535 return6571 return
6536 }6572 }
6537 6573
6574+ sys := i.agent.System
6538 changed := false6575 changed := false
6539- for _, addendum := range addenda {6576+ for idx := len(i.managedAutoSwarmAddenda) - 1; idx >= 0; idx-- {
6540- if strings.Contains(sys, addendum) {6577+ var removed bool
6541- changed = true6578+ sys, removed = removeLastAutoSwarmAddendum(sys, i.managedAutoSwarmAddenda[idx])
6542- sys = strings.ReplaceAll(sys, addendum, "")6579+ changed = changed || removed
6543- }
6544 }6580 }
6581+ i.managedAutoSwarmAddenda = nil
6545 if changed {6582 if changed {
6546 i.agent.System = strings.TrimRight(sys, "\n") + "\n"6583 i.agent.System = strings.TrimRight(sys, "\n") + "\n"
6547 }6584 }
M packages/agent/modes/swarm_slash.go +4 -4
@@ -276,7 +276,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin
276276 f := fields[i]
277277 switch {
278278 case f == "--agent":
279- if i+1 < len(fields) {
279+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
280280 subagent = fields[i+1]
281281 i += 2
282282 } else {
@@ -291,7 +291,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin
291291 // Consume the flag even when no value follows so a
292292 // dangling "--model" doesn't leak into the task. The
293293 // caller surfaces "missing task" instead.
294- if i+1 < len(fields) {
294+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
295295 model = fields[i+1]
296296 i += 2
297297 } else {
@@ -303,7 +303,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin
303303 i++
304304 continue
305305 case f == "--provider":
306- if i+1 < len(fields) {
306+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
307307 provider = fields[i+1]
308308 i += 2
309309 } else {
@@ -315,7 +315,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin
315315 i++
316316 continue
317317 case f == "--reasoning" || f == "--thinking":
318- if i+1 < len(fields) {
318+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
319319 reasoning = fields[i+1]
320320 i += 2
321321 } else {
@@ -276,7 +276,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin
276 f := fields[i]276 f := fields[i]
277 switch {277 switch {
278 case f == "--agent":278 case f == "--agent":
279- if i+1 < len(fields) {279+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
280 subagent = fields[i+1]280 subagent = fields[i+1]
281 i += 2281 i += 2
282 } else {282 } else {
@@ -291,7 +291,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin
291 // Consume the flag even when no value follows so a291 // Consume the flag even when no value follows so a
292 // dangling "--model" doesn't leak into the task. The292 // dangling "--model" doesn't leak into the task. The
293 // caller surfaces "missing task" instead.293 // caller surfaces "missing task" instead.
294- if i+1 < len(fields) {294+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
295 model = fields[i+1]295 model = fields[i+1]
296 i += 2296 i += 2
297 } else {297 } else {
@@ -303,7 +303,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin
303 i++303 i++
304 continue304 continue
305 case f == "--provider":305 case f == "--provider":
306- if i+1 < len(fields) {306+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
307 provider = fields[i+1]307 provider = fields[i+1]
308 i += 2308 i += 2
309 } else {309 } else {
@@ -315,7 +315,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin
315 i++315 i++
316 continue316 continue
317 case f == "--reasoning" || f == "--thinking":317 case f == "--reasoning" || f == "--thinking":
318- if i+1 < len(fields) {318+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
319 reasoning = fields[i+1]319 reasoning = fields[i+1]
320 i += 2320 i += 2
321 } else {321 } else {
M packages/agent/modes/swarm_slash_test.go +2 -0
@@ -213,6 +213,8 @@ func TestParseSpawnFlags(t *testing.T) {
213213 {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
214214 {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
215215 {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"},
216+ {"--agent --reasoning high review auth", "", "", "high", "", "review auth"},
217+ {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"},
216218 {"--thinking=max do x", "", "", "max", "", "do x"},
217219 // Only LEADING flags are consumed.
218220 {"do --model x", "", "", "", "", "do --model x"},
@@ -213,6 +213,8 @@ func TestParseSpawnFlags(t *testing.T) {
213 {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"},213 {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
214 {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"},214 {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
215 {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"},215 {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"},
216+ {"--agent --reasoning high review auth", "", "", "high", "", "review auth"},
217+ {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"},
216 {"--thinking=max do x", "", "", "max", "", "do x"},218 {"--thinking=max do x", "", "", "max", "", "do x"},
217 // Only LEADING flags are consumed.219 // Only LEADING flags are consumed.
218 {"do --model x", "", "", "", "", "do --model x"},220 {"do --model x", "", "", "", "", "do --model x"},
M packages/agent/subagents/profiles.go +9 -6
@@ -198,7 +198,10 @@ func load(path, source string) (*Profile, error) {
198198 if err != nil {
199199 return nil, err
200200 }
201- front, body := splitFrontmatter(string(raw))
201+ front, body, err := splitFrontmatter(string(raw))
202+ if err != nil {
203+ return nil, err
204+ }
202205 values, lists := parseFrontmatter(front)
203206 name := unquote(values["name"])
204207 if name == "" {
@@ -253,11 +256,11 @@ func load(path, source string) (*Profile, error) {
253256 return profile, nil
254257 }
255258
256-func splitFrontmatter(raw string) (front, body string) {
259+func splitFrontmatter(raw string) (front, body string, err error) {
257260 rest := strings.TrimLeft(raw, " \t\r\n")
258261 firstEnd := strings.IndexByte(rest, '\n')
259262 if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" {
260- return "", raw
263+ return "", raw, nil
261264 }
262265 rest = rest[firstEnd+1:]
263266 for offset := 0; offset <= len(rest); {
@@ -265,16 +268,16 @@ func splitFrontmatter(raw string) (front, body string) {
265268 if rel < 0 {
266269 // A document that starts a frontmatter block but never closes
267270 // it is malformed, not a body-only profile.
268- return "", ""
271+ return "", "", fmt.Errorf("frontmatter: missing closing delimiter")
269272 }
270273 end := offset + rel
271274 after := end + len("\n---")
272275 if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' {
273- return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n")
276+ return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n"), nil
274277 }
275278 offset = after
276279 }
277- return "", raw
280+ return "", raw, nil
278281 }
279282
280283 func parseFrontmatter(front string) (map[string]string, map[string][]string) {
@@ -198,7 +198,10 @@ func load(path, source string) (*Profile, error) {
198 if err != nil {198 if err != nil {
199 return nil, err199 return nil, err
200 }200 }
201- front, body := splitFrontmatter(string(raw))201+ front, body, err := splitFrontmatter(string(raw))
202+ if err != nil {
203+ return nil, err
204+ }
202 values, lists := parseFrontmatter(front)205 values, lists := parseFrontmatter(front)
203 name := unquote(values["name"])206 name := unquote(values["name"])
204 if name == "" {207 if name == "" {
@@ -253,11 +256,11 @@ func load(path, source string) (*Profile, error) {
253 return profile, nil256 return profile, nil
254 }257 }
255 258
256-func splitFrontmatter(raw string) (front, body string) {259+func splitFrontmatter(raw string) (front, body string, err error) {
257 rest := strings.TrimLeft(raw, " \t\r\n")260 rest := strings.TrimLeft(raw, " \t\r\n")
258 firstEnd := strings.IndexByte(rest, '\n')261 firstEnd := strings.IndexByte(rest, '\n')
259 if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" {262 if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" {
260- return "", raw263+ return "", raw, nil
261 }264 }
262 rest = rest[firstEnd+1:]265 rest = rest[firstEnd+1:]
263 for offset := 0; offset <= len(rest); {266 for offset := 0; offset <= len(rest); {
@@ -265,16 +268,16 @@ func splitFrontmatter(raw string) (front, body string) {
265 if rel < 0 {268 if rel < 0 {
266 // A document that starts a frontmatter block but never closes269 // A document that starts a frontmatter block but never closes
267 // it is malformed, not a body-only profile.270 // it is malformed, not a body-only profile.
268- return "", ""271+ return "", "", fmt.Errorf("frontmatter: missing closing delimiter")
269 }272 }
270 end := offset + rel273 end := offset + rel
271 after := end + len("\n---")274 after := end + len("\n---")
272 if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' {275 if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' {
273- return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n")276+ return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n"), nil
274 }277 }
275 offset = after278 offset = after
276 }279 }
277- return "", raw280+ return "", raw, nil
278 }281 }
279 282
280 func parseFrontmatter(front string) (map[string]string, map[string][]string) {283 func parseFrontmatter(front string) (map[string]string, map[string][]string) {
M packages/agent/subagents/profiles_test.go +10 -0
@@ -100,6 +100,16 @@ No.
100100 }
101101 }
102102
103+func TestLoadRejectsUnclosedFrontmatter(t *testing.T) {
104+ path := filepath.Join(t.TempDir(), "profile.md")
105+ if err := os.WriteFile(path, []byte("---\nname: reviewer\nInstructions without a closing delimiter.\n"), 0o600); err != nil {
106+ t.Fatal(err)
107+ }
108+ if _, err := load(path, "test"); err == nil || !strings.Contains(err.Error(), "missing closing delimiter") {
109+ t.Fatalf("load error = %v, want missing closing delimiter", err)
110+ }
111+}
112+
103113 func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
104114 cases := []struct {
105115 name string
@@ -100,6 +100,16 @@ No.
100 }100 }
101 }101 }
102 102
103+func TestLoadRejectsUnclosedFrontmatter(t *testing.T) {
104+ path := filepath.Join(t.TempDir(), "profile.md")
105+ if err := os.WriteFile(path, []byte("---\nname: reviewer\nInstructions without a closing delimiter.\n"), 0o600); err != nil {
106+ t.Fatal(err)
107+ }
108+ if _, err := load(path, "test"); err == nil || !strings.Contains(err.Error(), "missing closing delimiter") {
109+ t.Fatalf("load error = %v, want missing closing delimiter", err)
110+ }
111+}
112+
103 func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {113 func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) {
104 cases := []struct {114 cases := []struct {
105 name string115 name string

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