| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/agent" |
| 10 | "reasonix/internal/boot" |
| 11 | "reasonix/internal/command" |
| 12 | "reasonix/internal/control" |
| 13 | "reasonix/internal/event" |
| 14 | "reasonix/internal/i18n" |
| 15 | "reasonix/internal/provider" |
| 16 | "reasonix/internal/skill" |
| 17 | ) |
| 18 | |
| 19 | func TestRuntimeProfileDisplayLocalizesLabels(t *testing.T) { |
| 20 | defer i18n.DetectLanguage("en") |
| 21 | for _, tt := range []struct { |
| 22 | lang string |
| 23 | economy, balanced, delivery string |
| 24 | }{ |
| 25 | {lang: "en", economy: "economy", balanced: "balanced", delivery: "delivery"}, |
| 26 | {lang: "zh", economy: "轻量", balanced: "均衡", delivery: "交付"}, |
| 27 | {lang: "zh-TW", economy: "輕量", balanced: "均衡", delivery: "交付"}, |
| 28 | } { |
| 29 | t.Run(tt.lang, func(t *testing.T) { |
| 30 | i18n.DetectLanguage(tt.lang) |
| 31 | for profile, want := range map[string]string{ |
| 32 | boot.TokenModeEconomy: tt.economy, |
| 33 | boot.TokenModeFull: tt.balanced, |
| 34 | "balanced": tt.balanced, |
| 35 | boot.TokenModeDelivery: tt.delivery, |
| 36 | } { |
| 37 | if got := runtimeProfileDisplay(profile); got != want { |
| 38 | t.Errorf("runtimeProfileDisplay(%q) = %q, want %q", profile, got, want) |
| 39 | } |
| 40 | } |
| 41 | }) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestRenderWorkModesShowsAllOptionsAndCurrent(t *testing.T) { |
| 46 | out := renderWorkModes(100, boot.TokenModeFull) |
| 47 | for _, want := range []string{"economy", "balanced", "delivery", "current"} { |
| 48 | if !strings.Contains(out, want) { |
| 49 | t.Fatalf("renderWorkModes missing %q:\n%s", want, out) |
| 50 | } |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | func TestParseWorkModeKeepsBalancedAsFullInternally(t *testing.T) { |
| 55 | for input, want := range map[string]string{ |
| 56 | "economy": boot.TokenModeEconomy, |
| 57 | "balanced": boot.TokenModeFull, |
| 58 | "full": boot.TokenModeFull, |
| 59 | "delivery": boot.TokenModeDelivery, |
| 60 | } { |
| 61 | got, ok := parseWorkMode(input) |
| 62 | if !ok || got != want { |
| 63 | t.Errorf("parseWorkMode(%q) = %q, %v; want %q, true", input, got, ok, want) |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestWorkModeCompletionPublishesPrimaryCommandAndAliasArguments(t *testing.T) { |
| 69 | m := newTestChatTUI() |
| 70 | m.runtimeProfile = boot.TokenModeFull |
| 71 | if !hasLabel(m.slashItems(), "/work-mode") { |
| 72 | t.Fatal("slash completion missing /work-mode") |
| 73 | } |
| 74 | if hasLabel(m.slashItems(), "/profile") { |
| 75 | t.Fatal("technical /profile alias should not duplicate the primary command in the slash menu") |
| 76 | } |
| 77 | for _, input := range []string{"/work-mode ", "/profile "} { |
| 78 | items, _, ok := m.slashArgItems(input) |
| 79 | if !ok { |
| 80 | t.Fatalf("%q did not activate work-mode argument completion", input) |
| 81 | } |
| 82 | for _, want := range []string{"economy", "balanced", "delivery"} { |
| 83 | if !hasLabel(items, want) { |
| 84 | t.Errorf("%q completion missing %q: %v", input, want, labels(items)) |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestWorkModeHelpAndStatusUseUserFacingName(t *testing.T) { |
| 91 | if !hasLabel(builtinHelpItems(), "/work-mode") { |
| 92 | t.Fatal("built-in help missing /work-mode") |
| 93 | } |
| 94 | if hasLabel(builtinHelpItems(), "/profile") { |
| 95 | t.Fatal("built-in help should not duplicate the technical /profile alias") |
| 96 | } |
| 97 | m := newChatTUI(control.New(control.Options{Label: "model"}), "", make(chan event.Event, 1), 80) |
| 98 | m.runtimeProfile = boot.TokenModeDelivery |
| 99 | if got := m.workModeTag(); !strings.Contains(got, "delivery") { |
| 100 | t.Fatalf("work-mode status tag = %q, want delivery", got) |
| 101 | } |
| 102 | m.width = 30 |
| 103 | if got := m.computeStatusLineCount(m.width); got < 2 { |
| 104 | t.Fatalf("status-line count with work-mode tag = %d, want at least 2", got) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestWorkModeSwitchBuildsTargetProfileAndSwapsAtomically(t *testing.T) { |
| 109 | oldCtrl := control.New(control.Options{Label: "old"}) |
| 110 | oldCtrl.SetToolApprovalMode(control.ToolApprovalAuto) |
| 111 | oldCtrl.SetPlanMode(true) |
| 112 | newCtrl := control.New(control.Options{ |
| 113 | Label: "new", |
| 114 | Commands: []command.Command{{Name: "fresh-command"}}, |
| 115 | Skills: []skill.Skill{{Name: "Fresh Skill"}}, |
| 116 | }) |
| 117 | m := newChatTUI(oldCtrl, "", make(chan event.Event, 1), 100) |
| 118 | m.modelRef = "provider/model" |
| 119 | m.runtimeProfile = boot.TokenModeFull |
| 120 | var gotSpec controllerBuildSpec |
| 121 | var gotCarry []provider.Message |
| 122 | m.buildController = func(spec controllerBuildSpec, carry []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) { |
| 123 | gotSpec = spec |
| 124 | gotCarry = carry |
| 125 | return newCtrl, nil |
| 126 | } |
| 127 | |
| 128 | cmd := m.runWorkModeCommand("/work-mode delivery") |
| 129 | if cmd == nil { |
| 130 | t.Fatal("work-mode switch did not schedule a controller build") |
| 131 | } |
| 132 | if m.ctrl != oldCtrl || m.runtimeProfile != boot.TokenModeFull { |
| 133 | t.Fatal("controller or profile changed before the replacement build completed") |
| 134 | } |
| 135 | next, _ := m.Update(cmd()) |
| 136 | m = next.(chatTUI) |
| 137 | |
| 138 | if gotSpec.ModelRef != "provider/model" || gotSpec.RuntimeProfile != boot.TokenModeDelivery { |
| 139 | t.Fatalf("build spec = %+v, want current model and delivery profile", gotSpec) |
| 140 | } |
| 141 | if gotSpec.ToolApprovalMode != control.ToolApprovalAuto { |
| 142 | t.Fatalf("build spec approval mode = %q, want auto", gotSpec.ToolApprovalMode) |
| 143 | } |
| 144 | if !gotSpec.PlanMode { |
| 145 | t.Fatal("build spec did not preserve plan mode") |
| 146 | } |
| 147 | if len(gotCarry) != len(oldCtrl.History()) { |
| 148 | t.Fatalf("carried history length = %d, want %d", len(gotCarry), len(oldCtrl.History())) |
| 149 | } |
| 150 | if m.ctrl != newCtrl || m.runtimeProfile != boot.TokenModeDelivery { |
| 151 | t.Fatalf("successful switch did not install replacement controller/profile: ctrl=%p profile=%q", m.ctrl, m.runtimeProfile) |
| 152 | } |
| 153 | if m.label != newCtrl.Label() || len(m.commands) != 1 || len(m.skills) != 1 || m.host != newCtrl.Host() { |
| 154 | t.Fatalf("successful switch did not refresh controller metadata: label=%q commands=%d skills=%d", m.label, len(m.commands), len(m.skills)) |
| 155 | } |
| 156 | if len(m.oldControllers) != 1 || m.oldControllers[0] != oldCtrl { |
| 157 | t.Fatal("successful switch did not retain the old controller for exit-time cleanup") |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func TestWorkModeSwitchFailureKeepsOldControllerAndProfile(t *testing.T) { |
| 162 | session := agent.NewSession("system") |
| 163 | session.Add(provider.Message{Role: provider.RoleUser, Content: "keep this history"}) |
| 164 | exec := agent.New(nil, nil, session, agent.Options{}, event.Discard) |
| 165 | oldCtrl := control.New(control.Options{Label: "old", Executor: exec}) |
| 166 | m := newChatTUI(oldCtrl, "", make(chan event.Event, 1), 100) |
| 167 | m.modelRef = "provider/model" |
| 168 | m.runtimeProfile = boot.TokenModeEconomy |
| 169 | m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) { |
| 170 | return nil, errors.New("build failed") |
| 171 | } |
| 172 | |
| 173 | cmd := m.runSlashCommand("/profile delivery") |
| 174 | if cmd == nil { |
| 175 | t.Fatal("/profile alias did not schedule a controller build") |
| 176 | } |
| 177 | next, _ := m.Update(cmd()) |
| 178 | m = next.(chatTUI) |
| 179 | |
| 180 | if m.ctrl != oldCtrl || m.runtimeProfile != boot.TokenModeEconomy { |
| 181 | t.Fatalf("failed switch changed live runtime: ctrl=%p profile=%q", m.ctrl, m.runtimeProfile) |
| 182 | } |
| 183 | if history := m.ctrl.History(); len(history) != 2 || history[1].Content != "keep this history" { |
| 184 | t.Fatalf("failed switch lost history: %#v", history) |
| 185 | } |
| 186 | if m.modelSwitchPending || m.pendingModelSwitch != nil { |
| 187 | t.Fatal("failed switch left the runtime-switch gate pending") |
| 188 | } |
| 189 | if len(m.oldControllers) != 0 { |
| 190 | t.Fatal("failed switch retired the still-live old controller") |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | func TestWorkModeSwitchRejectsInvalidSameAndBusyRequests(t *testing.T) { |
| 195 | m := newTestChatTUI() |
| 196 | m.ctrl = control.New(control.Options{Label: "model"}) |
| 197 | m.modelRef = "provider/model" |
| 198 | m.runtimeProfile = boot.TokenModeFull |
| 199 | builds := 0 |
| 200 | m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) { |
| 201 | builds++ |
| 202 | return control.New(control.Options{Label: "new"}), nil |
| 203 | } |
| 204 | |
| 205 | for _, input := range []string{ |
| 206 | "/work-mode unknown", |
| 207 | "/work-mode balanced", |
| 208 | "/work-mode economy extra", |
| 209 | } { |
| 210 | if cmd := m.runWorkModeCommand(input); cmd != nil { |
| 211 | t.Fatalf("%q unexpectedly scheduled a build", input) |
| 212 | } |
| 213 | } |
| 214 | m.pendingApproval = &event.Approval{ID: "approval", Tool: "bash"} |
| 215 | if cmd := m.runWorkModeCommand("/work-mode economy"); cmd != nil { |
| 216 | t.Fatal("pending approval should block work-mode switching") |
| 217 | } |
| 218 | if builds != 0 { |
| 219 | t.Fatalf("rejected work-mode requests triggered %d builds", builds) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func TestWorkModeSwitchRejectsRunningTurn(t *testing.T) { |
| 224 | runner := &blockingTurnRunner{started: make(chan struct{})} |
| 225 | ctrl := control.New(control.Options{Runner: runner, Sink: event.Discard, SessionDir: t.TempDir(), Label: "model"}) |
| 226 | ctrl.Send("keep running") |
| 227 | <-runner.started |
| 228 | t.Cleanup(func() { |
| 229 | ctrl.Cancel() |
| 230 | deadline := time.Now().Add(2 * time.Second) |
| 231 | for ctrl.Running() && time.Now().Before(deadline) { |
| 232 | time.Sleep(time.Millisecond) |
| 233 | } |
| 234 | }) |
| 235 | |
| 236 | m := newTestChatTUI() |
| 237 | m.ctrl = ctrl |
| 238 | m.modelRef = "provider/model" |
| 239 | m.runtimeProfile = boot.TokenModeFull |
| 240 | builds := 0 |
| 241 | m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) { |
| 242 | builds++ |
| 243 | return control.New(control.Options{}), nil |
| 244 | } |
| 245 | if cmd := m.runWorkModeCommand("/work-mode delivery"); cmd != nil { |
| 246 | t.Fatal("running turn should block work-mode switching") |
| 247 | } |
| 248 | if builds != 0 { |
| 249 | t.Fatalf("running-turn guard triggered %d builds", builds) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | func TestRuntimeRebuildCommandsCarryCurrentWorkMode(t *testing.T) { |
| 254 | isolateUserConfig(t) |
| 255 | m := newTestChatTUI() |
| 256 | m.ctrl = control.New(control.Options{Label: "deepseek-flash"}) |
| 257 | m.modelRef = "deepseek-flash/deepseek-v4-flash" |
| 258 | m.runtimeProfile = boot.TokenModeDelivery |
| 259 | var specs []controllerBuildSpec |
| 260 | m.buildController = func(spec controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) { |
| 261 | specs = append(specs, spec) |
| 262 | return control.New(control.Options{Label: "deepseek-flash"}), nil |
| 263 | } |
| 264 | |
| 265 | cmd := m.runEffortCommand("/effort max") |
| 266 | if cmd == nil { |
| 267 | t.Fatal("effort switch did not schedule a rebuild") |
| 268 | } |
| 269 | next, _ := m.Update(cmd()) |
| 270 | m = next.(chatTUI) |
| 271 | |
| 272 | m.runModelSubcommand("/model deepseek-flash/another-model") |
| 273 | if m.pendingModelSwitch == nil { |
| 274 | t.Fatal("model switch did not schedule a rebuild") |
| 275 | } |
| 276 | next, _ = m.Update(m.pendingModelSwitch()) |
| 277 | m = next.(chatTUI) |
| 278 | |
| 279 | if !m.scheduleSkillSessionRefresh("test", "") { |
| 280 | t.Fatal("skill refresh did not schedule a rebuild") |
| 281 | } |
| 282 | next, _ = m.Update(m.pendingModelSwitch()) |
| 283 | m = next.(chatTUI) |
| 284 | |
| 285 | if len(specs) != 3 { |
| 286 | t.Fatalf("runtime rebuild count = %d, want 3", len(specs)) |
| 287 | } |
| 288 | if specs[0].EffortOverride == nil || *specs[0].EffortOverride != "max" { |
| 289 | t.Fatalf("effort rebuild override = %v, want max", specs[0].EffortOverride) |
| 290 | } |
| 291 | if specs[1].EffortOverride != nil || specs[2].EffortOverride != nil { |
| 292 | t.Fatalf("non-effort rebuilds unexpectedly replaced effort override: %+v", specs) |
| 293 | } |
| 294 | for i, spec := range specs { |
| 295 | if spec.RuntimeProfile != boot.TokenModeDelivery { |
| 296 | t.Errorf("rebuild %d lost delivery profile: %+v", i, spec) |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 |