| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "sync" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/charmbracelet/x/ansi" |
| 10 | |
| 11 | "reasonix/internal/boot" |
| 12 | "reasonix/internal/control" |
| 13 | "reasonix/internal/event" |
| 14 | "reasonix/internal/i18n" |
| 15 | "reasonix/internal/provider" |
| 16 | ) |
| 17 | |
| 18 | func resetPresetDeprecationForTest() { |
| 19 | presetDeprecationOnce = sync.Once{} |
| 20 | } |
| 21 | |
| 22 | func committedNotices(m chatTUI) string { |
| 23 | if m.pendingCommit == nil { |
| 24 | return "" |
| 25 | } |
| 26 | return ansi.Strip(strings.Join(*m.pendingCommit, "\n")) |
| 27 | } |
| 28 | |
| 29 | func TestParseAgentPresetAcceptsLegacyLabels(t *testing.T) { |
| 30 | for input, want := range map[string]string{ |
| 31 | "economy": "standard", |
| 32 | "light": "standard", |
| 33 | "eco": "standard", |
| 34 | "lite": "standard", |
| 35 | "balanced": "standard", |
| 36 | "full": "standard", |
| 37 | "standard": "standard", |
| 38 | "delivery": "standard", |
| 39 | "deliver": "standard", |
| 40 | } { |
| 41 | got, ok := parseAgentPreset(input) |
| 42 | if !ok || got != want { |
| 43 | t.Errorf("parseAgentPreset(%q) = %q, %v; want %q, true", input, got, ok, want) |
| 44 | } |
| 45 | } |
| 46 | if _, ok := parseAgentPreset("unknown"); ok { |
| 47 | t.Fatal("unknown preset should be rejected") |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestRetiredPresetCommandsAreHiddenFromCompletion(t *testing.T) { |
| 52 | m := newTestChatTUI() |
| 53 | for _, command := range []string{"/preset", "/profile", "/work-mode"} { |
| 54 | if hasLabel(m.slashItems(), command) { |
| 55 | t.Fatalf("retired compatibility command %q appeared in slash completion", command) |
| 56 | } |
| 57 | } |
| 58 | for _, input := range []string{"/preset ", "/work-mode ", "/profile "} { |
| 59 | if _, _, ok := m.slashArgItems(input); ok { |
| 60 | t.Fatalf("%q offered retired execution-mode argument completion", input) |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | func TestConsumeDeprecatedModeFlagsKeepsCompatibilityOutOfPublicFlagSets(t *testing.T) { |
| 66 | clean, mode, err := consumeDeprecatedModeFlags([]string{ |
| 67 | "--model", "deepseek", "--profile=delivery", "fix it", "--preset", "light", |
| 68 | }, "profile", "preset") |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | if mode != "delivery" { |
| 73 | t.Fatalf("mode = %q, want profile precedence delivery", mode) |
| 74 | } |
| 75 | if got := strings.Join(clean, " "); got != "--model deepseek fix it" { |
| 76 | t.Fatalf("clean args = %q", got) |
| 77 | } |
| 78 | |
| 79 | clean, mode, err = consumeDeprecatedModeFlags([]string{"run", "--", "--profile", "delivery"}, "profile") |
| 80 | if err != nil || mode != "" || strings.Join(clean, " ") != "run -- --profile delivery" { |
| 81 | t.Fatalf("post-boundary args: clean=%v mode=%q err=%v", clean, mode, err) |
| 82 | } |
| 83 | if _, _, err := consumeDeprecatedModeFlags([]string{"--profile"}, "profile"); err == nil { |
| 84 | t.Fatal("missing compatibility flag value must fail") |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestLegacyModeFlagsAreHiddenFromCommandHelp(t *testing.T) { |
| 89 | tests := []struct { |
| 90 | name string |
| 91 | run func() int |
| 92 | }{ |
| 93 | {name: "run", run: func() int { return runAgent([]string{"--help"}, "dev") }}, |
| 94 | {name: "chat", run: func() int { return chatREPL([]string{"--help"}, "dev") }}, |
| 95 | {name: "web", run: func() int { return runWeb([]string{"--help"}) }}, |
| 96 | {name: "acp", run: func() int { return acpCommand([]string{"--help"}, "dev") }}, |
| 97 | } |
| 98 | for _, tt := range tests { |
| 99 | t.Run(tt.name, func(t *testing.T) { |
| 100 | out := captureStdout(t, func() { |
| 101 | if code := tt.run(); code != 0 { |
| 102 | t.Fatalf("help exit code = %d, want 0", code) |
| 103 | } |
| 104 | }) |
| 105 | if strings.Contains(out, "--profile") || strings.Contains(out, "--preset") { |
| 106 | t.Fatalf("legacy mode flag leaked into help:\n%s", out) |
| 107 | } |
| 108 | }) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestRetiredPresetCommandsAreHiddenFromHelp(t *testing.T) { |
| 113 | for _, command := range []string{"/preset", "/profile", "/work-mode"} { |
| 114 | if hasLabel(builtinHelpItems(), command) { |
| 115 | t.Fatalf("built-in help listed retired command %q", command) |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestPresetTagStaysHiddenForLegacyInputs(t *testing.T) { |
| 121 | ctrl := newOwnedTestController(t, control.Options{}) |
| 122 | m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) |
| 123 | if tag := m.presetTag(); tag != "" { |
| 124 | t.Fatalf("standard floor should stay quiet, got %q", tag) |
| 125 | } |
| 126 | if err := ctrl.SetQualityFloor(control.QualityFloorDelivery); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | if tag := m.presetTag(); tag != "" { |
| 130 | t.Fatalf("retired delivery setting surfaced a footer tag: %q", tag) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestPresetCommandIsInPlaceCompatibilityNoOp(t *testing.T) { |
| 135 | resetPresetDeprecationForTest() |
| 136 | oldCtrl := newOwnedTestController(t, control.Options{Label: "old"}) |
| 137 | oldCtrl.SetToolApprovalMode(control.ToolApprovalAuto) |
| 138 | oldCtrl.SetPlanMode(true) |
| 139 | m := newChatTUI(oldCtrl, "", make(chan event.Event, 1), 100) |
| 140 | m.modelRef = "provider/model" |
| 141 | builds := 0 |
| 142 | m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) { |
| 143 | builds++ |
| 144 | return newOwnedTestController(t, control.Options{Label: "new"}), nil |
| 145 | } |
| 146 | |
| 147 | cmd := m.runWorkModeCommand("/preset delivery") |
| 148 | if cmd != nil { |
| 149 | t.Fatal("/preset must not schedule a controller rebuild") |
| 150 | } |
| 151 | if m.ctrl != oldCtrl { |
| 152 | t.Fatal("controller must stay the same instance") |
| 153 | } |
| 154 | if m.ctrl.AgentPreset() != boot.AgentPresetStandard { |
| 155 | t.Fatalf("controller preset = %q, want standard", m.ctrl.AgentPreset()) |
| 156 | } |
| 157 | if builds != 0 { |
| 158 | t.Fatalf("unexpected rebuilds: %d", builds) |
| 159 | } |
| 160 | if out := committedNotices(m); !strings.Contains(out, i18n.M.WorkModeDeprecatedNotice) { |
| 161 | t.Fatalf("legacy /preset missing retirement notice:\n%s", out) |
| 162 | } |
| 163 | |
| 164 | if cmd := m.runWorkModeCommand("/preset light"); cmd != nil { |
| 165 | t.Fatal("second /preset must not rebuild") |
| 166 | } |
| 167 | if m.ctrl.AgentPreset() != boot.AgentPresetStandard { |
| 168 | t.Fatalf("light must fold to standard, got %q", m.ctrl.AgentPreset()) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestPresetCommandRejectsInvalidValue(t *testing.T) { |
| 173 | resetPresetDeprecationForTest() |
| 174 | m := newTestChatTUI() |
| 175 | m.ctrl = newOwnedTestController(t, control.Options{Label: "model"}) |
| 176 | m.modelRef = "provider/model" |
| 177 | builds := 0 |
| 178 | m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) { |
| 179 | builds++ |
| 180 | return newOwnedTestController(t, control.Options{Label: "new"}), nil |
| 181 | } |
| 182 | |
| 183 | if cmd := m.runWorkModeCommand("/preset unknown"); cmd != nil { |
| 184 | t.Fatal("invalid /preset unexpectedly scheduled a rebuild") |
| 185 | } |
| 186 | out := committedNotices(m) |
| 187 | if !strings.Contains(out, i18n.M.WorkModeUsage) { |
| 188 | t.Fatalf("invalid /preset missing usage:\n%s", out) |
| 189 | } |
| 190 | if m.ctrl.AgentPreset() != boot.AgentPresetStandard { |
| 191 | t.Fatalf("controller preset = %q, want standard", m.ctrl.AgentPreset()) |
| 192 | } |
| 193 | if builds != 0 { |
| 194 | t.Fatalf("rejected preset request triggered %d builds", builds) |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | func TestPresetCommandSwitchesWhenBusy(t *testing.T) { |
| 199 | resetPresetDeprecationForTest() |
| 200 | m := newTestChatTUI() |
| 201 | m.ctrl = newOwnedTestController(t, control.Options{Label: "model"}) |
| 202 | m.modelRef = "provider/model" |
| 203 | m.pendingApproval = &event.Approval{ID: "approval", Tool: "bash"} |
| 204 | builds := 0 |
| 205 | m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) { |
| 206 | builds++ |
| 207 | return newOwnedTestController(t, control.Options{Label: "new"}), nil |
| 208 | } |
| 209 | if cmd := m.runWorkModeCommand("/preset light"); cmd != nil { |
| 210 | t.Fatal("busy /preset must not rebuild") |
| 211 | } |
| 212 | if m.ctrl.AgentPreset() != boot.AgentPresetStandard { |
| 213 | t.Fatalf("busy /preset AgentPreset = %q, want standard (light folds)", m.ctrl.AgentPreset()) |
| 214 | } |
| 215 | if builds != 0 { |
| 216 | t.Fatalf("busy /preset triggered %d builds", builds) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | func TestPresetCommandSwitchesDuringRunningTurn(t *testing.T) { |
| 221 | resetPresetDeprecationForTest() |
| 222 | runner := &blockingTurnRunner{started: make(chan struct{})} |
| 223 | ctrl := newOwnedTestController(t, control.Options{Runner: runner, Sink: event.Discard, SessionDir: t.TempDir(), Label: "model"}) |
| 224 | ctrl.Send("keep running") |
| 225 | <-runner.started |
| 226 | t.Cleanup(func() { |
| 227 | ctrl.Cancel() |
| 228 | deadline := time.Now().Add(2 * time.Second) |
| 229 | for ctrl.Running() && time.Now().Before(deadline) { |
| 230 | time.Sleep(time.Millisecond) |
| 231 | } |
| 232 | }) |
| 233 | |
| 234 | m := newTestChatTUI() |
| 235 | m.ctrl = ctrl |
| 236 | m.modelRef = "provider/model" |
| 237 | builds := 0 |
| 238 | m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) { |
| 239 | builds++ |
| 240 | return newOwnedTestController(t, control.Options{}), nil |
| 241 | } |
| 242 | if cmd := m.runWorkModeCommand("/preset delivery"); cmd != nil { |
| 243 | t.Fatal("running-turn /preset must not rebuild") |
| 244 | } |
| 245 | if m.ctrl.AgentPreset() != boot.AgentPresetStandard { |
| 246 | t.Fatalf("running-turn /preset AgentPreset = %q, want standard", m.ctrl.AgentPreset()) |
| 247 | } |
| 248 | if builds != 0 { |
| 249 | t.Fatalf("running-turn /preset triggered %d builds", builds) |
| 250 | } |
| 251 | if out := committedNotices(m); !strings.Contains(out, i18n.M.WorkModeDeprecatedNotice) { |
| 252 | t.Fatalf("running-turn /preset missing retirement notice:\n%s", out) |
| 253 | } |
| 254 | } |
| 255 |