返回 DeepSeek-Reasonix
cli_test.go
根目录 / internal / cli / cli_test.go
1 package cli
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "errors"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "reflect"
14 "strings"
15 "sync/atomic"
16 "testing"
17
18 "reasonix/internal/agent"
19 "reasonix/internal/boot"
20 "reasonix/internal/config"
21 "reasonix/internal/control"
22 "reasonix/internal/event"
23 "reasonix/internal/i18n"
24 "reasonix/internal/notify"
25 "reasonix/internal/provider"
26 "reasonix/internal/telemetry"
27 )
28
29 func TestChdirTo(t *testing.T) {
30 orig, err := os.Getwd()
31 if err != nil {
32 t.Fatal(err)
33 }
34
35 if rc := chdirTo(""); rc != 0 {
36 t.Fatalf(`chdirTo("") = %d, want 0`, rc)
37 }
38 if cwd, _ := os.Getwd(); cwd != orig {
39 t.Fatalf(`chdirTo("") moved cwd to %q`, cwd)
40 }
41
42 tmp := t.TempDir()
43 // Restore CWD before TempDir's RemoveAll runs (LIFO ordering): Windows can't
44 // delete a directory that is still the process working directory.
45 t.Cleanup(func() { _ = os.Chdir(orig) })
46 if rc := chdirTo(tmp); rc != 0 {
47 t.Fatalf("chdirTo(tmp) = %d, want 0", rc)
48 }
49 got, _ := filepath.EvalSymlinks(mustGetwd(t))
50 want, _ := filepath.EvalSymlinks(tmp)
51 if got != want {
52 t.Fatalf("cwd = %q, want %q", got, want)
53 }
54
55 if rc := chdirTo(filepath.Join(tmp, "does-not-exist")); rc != 2 {
56 t.Fatalf("chdirTo(missing) = %d, want 2", rc)
57 }
58 }
59
60 func TestModelForResumePathUsesStoredModelWhenAvailable(t *testing.T) {
61 dir := t.TempDir()
62 path := filepath.Join(dir, "session.jsonl")
63 session := agent.NewSession("sys")
64 session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
65 if err := session.Save(path); err != nil {
66 t.Fatal(err)
67 }
68 if err := agent.SetBranchModelPreserveUpdated(path, "saved/model"); err != nil {
69 t.Fatal(err)
70 }
71 cfg := &config.Config{
72 DefaultModel: "default/model",
73 Providers: []config.ProviderEntry{
74 {Name: "default", Kind: "openai", BaseURL: "https://default.invalid/v1", Model: "model"},
75 {Name: "saved", Kind: "openai", BaseURL: "https://saved.invalid/v1", Model: "model"},
76 },
77 }
78
79 if got := modelForResumePath("", path, cfg); got != "saved/model" {
80 t.Fatalf("modelForResumePath = %q, want saved/model", got)
81 }
82 if got := modelForResumePath("explicit/model", path, cfg); got != "explicit/model" {
83 t.Fatalf("explicit model was overwritten: %q", got)
84 }
85 if got := modelForResumePath("", filepath.Join(dir, "missing.jsonl"), cfg); got != "" {
86 t.Fatalf("missing session model = %q, want empty fallback", got)
87 }
88 cfg.Providers = cfg.Providers[:1]
89 if got := modelForResumePath("", path, cfg); got != "" {
90 t.Fatalf("unknown stored model = %q, want empty fallback", got)
91 }
92 }
93
94 func TestLoadResumableSessionRejectsCleanupPending(t *testing.T) {
95 dir := t.TempDir()
96 path := filepath.Join(dir, "pending.jsonl")
97 saveTestSession(t, path, "pending prompt")
98 if err := agent.MarkCleanupPending(path, "delete"); err != nil {
99 t.Fatal(err)
100 }
101
102 if _, err := loadResumableSession(path); err == nil || !strings.Contains(err.Error(), "pending cleanup") {
103 t.Fatalf("loadResumableSession cleanup-pending error = %v, want pending cleanup", err)
104 }
105 }
106
107 func TestRunResumeRejectsCleanupPending(t *testing.T) {
108 isolateCLIConfigHome(t)
109
110 path := filepath.Join(t.TempDir(), "pending-run.jsonl")
111 saveTestSession(t, path, "pending prompt")
112 if err := agent.MarkCleanupPending(path, "delete"); err != nil {
113 t.Fatal(err)
114 }
115
116 errOut := captureStderr(t, func() {
117 if rc := runAgent([]string{"--resume", path, "continue task"}, "dev"); rc != 1 {
118 t.Fatalf("run --resume cleanup-pending rc = %d, want 1", rc)
119 }
120 })
121 if !strings.Contains(errOut, "pending cleanup") {
122 t.Fatalf("run --resume cleanup-pending stderr = %q, want pending cleanup", errOut)
123 }
124 }
125
126 func TestServeResumeRejectsCleanupPending(t *testing.T) {
127 isolateCLIConfigHome(t)
128
129 path := filepath.Join(t.TempDir(), "pending-serve.jsonl")
130 saveTestSession(t, path, "pending prompt")
131 if err := agent.MarkCleanupPending(path, "delete"); err != nil {
132 t.Fatal(err)
133 }
134
135 errOut := captureStderr(t, func() {
136 if rc := runServe([]string{"--resume", path, "--addr", "127.0.0.1:0"}); rc != 1 {
137 t.Fatalf("serve --resume cleanup-pending rc = %d, want 1", rc)
138 }
139 })
140 if !strings.Contains(errOut, "pending cleanup") {
141 t.Fatalf("serve --resume cleanup-pending stderr = %q, want pending cleanup", errOut)
142 }
143 }
144
145 func TestServeRejectsUnknownAuthMode(t *testing.T) {
146 isolateCLIConfigHome(t)
147
148 errOut := captureStderr(t, func() {
149 if rc := runServe([]string{"--auth", "tokne", "--addr", "127.0.0.1:0"}); rc != 1 {
150 t.Fatalf("serve --auth tokne rc = %d, want 1", rc)
151 }
152 })
153 if !strings.Contains(errOut, "auth mode must be none, token, or password") {
154 t.Fatalf("serve --auth tokne stderr = %q, want auth mode validation", errOut)
155 }
156 }
157
158 func TestServePasswordAuthRequiresPasswordMaterial(t *testing.T) {
159 isolateCLIConfigHome(t)
160
161 errOut := captureStderr(t, func() {
162 if rc := runServe([]string{"--auth", "password", "--addr", "127.0.0.1:0"}); rc != 1 {
163 t.Fatalf("serve --auth password without password rc = %d, want 1", rc)
164 }
165 })
166 if !strings.Contains(errOut, "auth mode password requires --password or serve.password_hash") {
167 t.Fatalf("serve --auth password stderr = %q, want password material validation", errOut)
168 }
169 }
170
171 func TestReserveNativeScrollbackFrameWritesOnlyNewlines(t *testing.T) {
172 var b bytes.Buffer
173 reserveNativeScrollbackFrame(&b, 3)
174 if got := b.String(); got != "\n\n\n" {
175 t.Fatalf("reserveNativeScrollbackFrame wrote %q, want only three newlines", got)
176 }
177
178 reserveNativeScrollbackFrame(&b, 0)
179 if got := b.String(); got != "\n\n\n" {
180 t.Fatalf("reserveNativeScrollbackFrame(0) changed output to %q", got)
181 }
182 }
183
184 func TestPrepareNativeScrollbackClearsBeforeFrame(t *testing.T) {
185 var b bytes.Buffer
186 prepareNativeScrollback(&b, 2)
187 if got, want := b.String(), "\x1B[3J\x1B[2J\x1B[H\n\n"; got != want {
188 t.Fatalf("prepareNativeScrollback wrote %q, want %q", got, want)
189 }
190 }
191
192 func mustGetwd(t *testing.T) string {
193 t.Helper()
194 cwd, err := os.Getwd()
195 if err != nil {
196 t.Fatal(err)
197 }
198 return cwd
199 }
200
201 func isolateCLIConfigHome(t *testing.T) string {
202 t.Helper()
203 home := t.TempDir()
204 t.Setenv("HOME", home)
205 // Keep tests on the default-path code path while preventing a caller's
206 // higher-priority REASONIX_HOME from escaping this temporary home.
207 t.Setenv("REASONIX_HOME", "")
208 if err := os.Unsetenv("REASONIX_HOME"); err != nil {
209 t.Fatalf("unset REASONIX_HOME: %v", err)
210 }
211 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
212 t.Setenv("USERPROFILE", home)
213 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
214 t.Setenv("AppData", filepath.Join(home, "AppData"))
215 t.Chdir(t.TempDir())
216 return home
217 }
218
219 func TestIsolateCLIConfigHomeOverridesExistingReasonixHome(t *testing.T) {
220 externalHome := t.TempDir()
221 t.Setenv("REASONIX_HOME", externalHome)
222
223 home := isolateCLIConfigHome(t)
224
225 got := config.UserConfigPath()
226 rel, err := filepath.Rel(home, got)
227 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
228 t.Fatalf("UserConfigPath() = %q, outside isolated home %q", got, home)
229 }
230 }
231
232 func TestMCPMigrationWaitsForCLIWorkspace(t *testing.T) {
233 isolateCLIConfigHome(t)
234 cwd := mustGetwd(t)
235 if err := os.WriteFile(filepath.Join(cwd, "reasonix.toml"), []byte(`
236 [[plugins]]
237 name = "cwd-project"
238 command = "cwd-project-bin"
239 `), 0o644); err != nil {
240 t.Fatal(err)
241 }
242
243 migrateLegacyConfigForCLI()
244 if cfg := config.LoadForEdit(config.UserConfigPath()); hasPluginNamed(cfg, "cwd-project") {
245 t.Fatalf("early CLI legacy migration imported the cwd project plugin: %+v", cfg.Plugins)
246 }
247
248 migrateMCPConfigForCLIWorkspace()
249 if cfg := config.LoadForEdit(config.UserConfigPath()); !hasPluginNamed(cfg, "cwd-project") {
250 t.Fatalf("workspace-aware CLI migration did not import project plugin: %+v", cfg.Plugins)
251 }
252 }
253
254 func hasPluginNamed(cfg *config.Config, name string) bool {
255 if cfg == nil {
256 return false
257 }
258 for _, plugin := range cfg.Plugins {
259 if plugin.Name == name {
260 return true
261 }
262 }
263 return false
264 }
265
266 func TestMetadataCommandsDoNotProbeTerminalTheme(t *testing.T) {
267 defer func(prev func() (terminalRGB, bool)) { terminalProbe = prev }(terminalProbe)
268 terminalProbe = func() (terminalRGB, bool) {
269 t.Fatal("metadata command should not query terminal background")
270 return terminalRGB{}, false
271 }
272
273 out := captureStdout(t, func() {
274 if rc := Run([]string{"version"}, "test-version"); rc != 0 {
275 t.Fatalf("version rc = %d, want 0", rc)
276 }
277 })
278 if !strings.Contains(out, "reasonix test-version") {
279 t.Fatalf("version output = %q", out)
280 }
281
282 out = captureStdout(t, func() {
283 if rc := Run([]string{"help"}, "test-version"); rc != 0 {
284 t.Fatalf("help rc = %d, want 0", rc)
285 }
286 })
287 if !strings.Contains(out, "Usage:") && !strings.Contains(out, "用法:") {
288 t.Fatalf("help output missing usage:\n%s", out)
289 }
290 if !strings.Contains(out, "reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] [--copy] [--output-format FORMAT] <task>") {
291 t.Fatalf("help output missing run resume flags:\n%s", out)
292 }
293 }
294
295 func TestRunDispatchesACPLongFlagAlias(t *testing.T) {
296 out, errOut := captureCLIOutput(t, func() {
297 if rc := Run([]string{"--acp", "-h"}, "test-version"); rc != 0 {
298 t.Fatalf("Run --acp -h rc = %d, want 0", rc)
299 }
300 })
301 if !strings.Contains(out, "Usage of acp:") {
302 t.Fatalf("--acp should dispatch to the ACP command, got stdout:\n%s", out)
303 }
304 if errOut != "" {
305 t.Fatalf("--acp help wrote stderr: %q", errOut)
306 }
307 if strings.Contains(out, "unknown command") {
308 t.Fatalf("--acp should not be treated as an unknown command:\n%s", out)
309 }
310 }
311
312 func TestRunDefaultsToInteractiveSession(t *testing.T) {
313 isolateCLIConfigHome(t)
314
315 prev := runInteractiveSession
316 prevInteractive := cliIsInteractive
317 t.Cleanup(func() {
318 runInteractiveSession = prev
319 cliIsInteractive = prevInteractive
320 })
321 cliIsInteractive = func() bool { return true }
322
323 var gotArgs []string
324 runInteractiveSession = func(args []string, _ string) int {
325 gotArgs = append([]string(nil), args...)
326 return 17
327 }
328
329 if rc := Run(nil, "test-version"); rc != 17 {
330 t.Fatalf("Run(nil) rc = %d, want 17", rc)
331 }
332 if gotArgs != nil {
333 t.Fatalf("interactive args = %#v, want nil", gotArgs)
334 }
335 }
336
337 func TestRunDispatchesProfileFlagToInteractiveSession(t *testing.T) {
338 isolateCLIConfigHome(t)
339
340 prev := runInteractiveSession
341 prevInteractive := cliIsInteractive
342 t.Cleanup(func() {
343 runInteractiveSession = prev
344 cliIsInteractive = prevInteractive
345 })
346 cliIsInteractive = func() bool { return true }
347
348 var gotArgs []string
349 runInteractiveSession = func(args []string, _ string) int {
350 gotArgs = append([]string(nil), args...)
351 return 17
352 }
353
354 if rc := Run([]string{"--profile", "delivery"}, "test-version"); rc != 17 {
355 t.Fatalf("Run --profile delivery rc = %d, want 17 (interactive session dispatch)", rc)
356 }
357 want := []string{"--profile", "delivery"}
358 if !reflect.DeepEqual(gotArgs, want) {
359 t.Fatalf("interactive args = %#v, want %#v", gotArgs, want)
360 }
361 }
362
363 func TestRunNoArgsNonInteractivePrintsUsage(t *testing.T) {
364 isolateCLIConfigHome(t)
365
366 prev := runInteractiveSession
367 prevInteractive := cliIsInteractive
368 t.Cleanup(func() {
369 runInteractiveSession = prev
370 cliIsInteractive = prevInteractive
371 })
372 cliIsInteractive = func() bool { return false }
373 runInteractiveSession = func(args []string, _ string) int {
374 t.Fatalf("non-interactive no-arg Run should not start session with %#v", args)
375 return 99
376 }
377
378 out := captureStdout(t, func() {
379 if rc := Run(nil, "test-version"); rc != 0 {
380 t.Fatalf("Run(nil) rc = %d, want 0", rc)
381 }
382 })
383 if !strings.Contains(out, "reasonix —") || !strings.Contains(out, "reasonix run") {
384 t.Fatalf("non-interactive no-arg Run should print usage, got:\n%s", out)
385 }
386 }
387
388 func TestRunRoutesBareInteractiveFlagsToSession(t *testing.T) {
389 isolateCLIConfigHome(t)
390
391 prev := runInteractiveSession
392 t.Cleanup(func() { runInteractiveSession = prev })
393
394 for _, args := range [][]string{
395 {"--continue"},
396 {"--continue=true"},
397 {"-c"},
398 {"-c=true"},
399 {"--resume=true"},
400 {"-r=true"},
401 {"--yolo=true"},
402 {"--dangerously-skip-permissions=true"},
403 {"--permission-mode=plan"},
404 {"--effort=max"},
405 } {
406 var gotArgs []string
407 runInteractiveSession = func(args []string, _ string) int {
408 gotArgs = append([]string(nil), args...)
409 return 23
410 }
411
412 if rc := Run(args, "test-version"); rc != 23 {
413 t.Fatalf("Run(%#v) rc = %d, want 23", args, rc)
414 }
415 if !reflect.DeepEqual(gotArgs, args) {
416 t.Fatalf("interactive args = %#v, want %#v", gotArgs, args)
417 }
418 }
419 }
420
421 func TestRunReportsFlagParseErrors(t *testing.T) {
422 isolateCLIConfigHome(t)
423
424 tests := []struct {
425 name string
426 args []string
427 want string
428 }{
429 {name: "run unknown flag", args: []string{"run", "--unknown"}, want: "unknown flag: --unknown"},
430 {name: "run invalid value", args: []string{"run", "--max-steps=invalid"}, want: "invalid argument \"invalid\" for \"--max-steps\" flag"},
431 {name: "run missing value", args: []string{"run", "--model"}, want: "flag needs an argument: --model"},
432 {name: "chat unknown flag", args: []string{"chat", "--unknown"}, want: "unknown flag: --unknown"},
433 {name: "serve unknown flag", args: []string{"serve", "--unknown"}, want: "flag provided but not defined: -unknown"},
434 }
435
436 for _, tt := range tests {
437 t.Run(tt.name, func(t *testing.T) {
438 stderr := captureStderr(t, func() {
439 if rc := Run(tt.args, "test-version"); rc != 2 {
440 t.Fatalf("Run(%q) rc = %d, want 2", tt.args, rc)
441 }
442 })
443 if !strings.Contains(stderr, tt.want) {
444 t.Fatalf("Run(%q) stderr = %q, want %q", tt.args, stderr, tt.want)
445 }
446 if strings.Contains(stderr, "Usage of") {
447 t.Fatalf("Run(%q) should print a concise error, got:\n%s", tt.args, stderr)
448 }
449 })
450 }
451 }
452
453 func TestSubcommandHelpReturnsSuccess(t *testing.T) {
454 isolateCLIConfigHome(t)
455
456 tests := []struct {
457 name string
458 args []string
459 want string
460 }{
461 {name: "run", args: []string{"run", "--help"}, want: "Usage of run:"},
462 {name: "chat", args: []string{"chat", "--help"}, want: "Usage of reasonix:"},
463 {name: "serve", args: []string{"serve", "--help"}, want: "Usage of serve:"},
464 {name: "upgrade", args: []string{"upgrade", "--help"}, want: "Usage of upgrade:"},
465 {name: "remote connect", args: []string{"remote", "connect", "--help"}, want: "Usage of remote connect:"},
466 {name: "remote add before name", args: []string{"remote", "add", "--help"}, want: remoteAddUsage},
467 {name: "remote add before target", args: []string{"remote", "add", "box", "--help"}, want: remoteAddUsage},
468 {name: "remote serve before action", args: []string{"remote", "serve", "--help"}, want: remoteServeUsage},
469 {name: "remote serve before name", args: []string{"remote", "serve", "start", "--help"}, want: remoteServeUsage},
470 {name: "subagent create", args: []string{"subagent", "create", "--help"}, want: subagentUsageText},
471 {name: "subagent edit", args: []string{"subagent", "edit", "--help"}, want: subagentUsageText},
472 {name: "subagent delete", args: []string{"subagent", "delete", "--help"}, want: subagentUsageText},
473 {name: "subagent try", args: []string{"subagent", "try", "--help"}, want: subagentUsageText},
474 {name: "subagent run", args: []string{"subagent", "run", "--help"}, want: subagentUsageText},
475 }
476 for _, tt := range tests {
477 t.Run(tt.name, func(t *testing.T) {
478 stdout, stderr := captureCLIOutput(t, func() {
479 if rc := Run(tt.args, "test-version"); rc != 0 {
480 t.Fatalf("Run(%q) rc = %d, want 0", tt.args, rc)
481 }
482 })
483 if !strings.Contains(stdout, tt.want) {
484 t.Fatalf("Run(%q) help missing %q:\n%s", tt.args, tt.want, stdout)
485 }
486 if stderr != "" {
487 t.Fatalf("Run(%q) help wrote stderr: %q", tt.args, stderr)
488 }
489 if strings.Contains(stdout, "help requested") {
490 t.Fatalf("Run(%q) reported help as an error:\n%s", tt.args, stdout)
491 }
492 })
493 }
494 }
495
496 func TestRunPrintAliasDispatchesRunFlags(t *testing.T) {
497 isolateCLIConfigHome(t)
498 out, errOut := captureCLIOutput(t, func() {
499 if rc := Run([]string{"-p", "-h"}, "test-version"); rc != 0 {
500 t.Fatalf("Run(-p -h) rc = %d, want 0", rc)
501 }
502 })
503 if !strings.Contains(out, "Usage of run:") {
504 t.Fatalf("-p should dispatch to one-shot run flags, got:\n%s", out)
505 }
506 if errOut != "" {
507 t.Fatalf("-p help wrote stderr: %q", errOut)
508 }
509 }
510
511 // TestRunPrintFlagAfterLeadingFlagsDispatchesRun covers `reasonix --model X -p`:
512 // a print flag trailing other top-level flags must still route to `run --print`,
513 // not into the interactive session parser (which has no -p and returns 2).
514 func TestRunPrintFlagAfterLeadingFlagsDispatchesRun(t *testing.T) {
515 isolateCLIConfigHome(t)
516 prev := runInteractiveSession
517 t.Cleanup(func() { runInteractiveSession = prev })
518 runInteractiveSession = func([]string, string) int {
519 t.Fatal("print flag after leading flags must not route to the interactive session")
520 return 0
521 }
522 out, errOut := captureCLIOutput(t, func() {
523 if rc := Run([]string{"--model", "x", "-p", "-h"}, "test-version"); rc != 0 {
524 t.Fatalf("Run(--model x -p -h) rc = %d, want 0", rc)
525 }
526 })
527 if !strings.Contains(out, "Usage of run:") {
528 t.Fatalf("--model x -p should dispatch to one-shot run flags, got:\n%s", out)
529 }
530 if errOut != "" {
531 t.Fatalf("--model x -p help wrote stderr: %q", errOut)
532 }
533 }
534
535 func TestParsePermissionModeClaudeAliases(t *testing.T) {
536 tests := map[string]cliPermissionMode{
537 "ask": {approval: control.ToolApprovalAsk},
538 "manual": {approval: control.ToolApprovalAsk},
539 "acceptEdits": {approval: control.ToolApprovalAsk, allow: []string{"write_file", "edit_file", "multi_edit", "move_file", "notebook_edit", "delete_range", "delete_symbol"}},
540 "dontAsk": {approval: control.ToolApprovalDontAsk},
541 "plan": {approval: control.ToolApprovalAsk, plan: true},
542 "bypassPermissions": {approval: control.ToolApprovalYolo},
543 }
544 for input, want := range tests {
545 got, err := parsePermissionMode(input)
546 if err != nil || !reflect.DeepEqual(got, want) {
547 t.Errorf("parsePermissionMode(%q) = (%+v, %v), want %+v", input, got, err, want)
548 }
549 }
550 }
551
552 func TestResolveRunPermissionModeRequiresExplicitAuto(t *testing.T) {
553 if got, err := resolveRunPermissionMode("ask", false, false); err != nil || got != "ask" {
554 t.Fatalf("default run permission mode = (%q, %v), want ask", got, err)
555 }
556 if got, err := resolveRunPermissionMode("ask", true, false); err != nil || got != "auto" {
557 t.Fatalf("-y run permission mode = (%q, %v), want auto", got, err)
558 }
559 if got, err := resolveRunPermissionMode("dontAsk", true, true); err == nil || got != "" {
560 t.Fatalf("combined permission flags = (%q, %v), want conflict", got, err)
561 }
562 }
563
564 func TestRunKeepsChatAndCodeCompatibilityAliases(t *testing.T) {
565 isolateCLIConfigHome(t)
566
567 prev := runInteractiveSession
568 t.Cleanup(func() { runInteractiveSession = prev })
569
570 var calls [][]string
571 runInteractiveSession = func(args []string, _ string) int {
572 calls = append(calls, append([]string(nil), args...))
573 return 0
574 }
575
576 if rc := Run([]string{"chat", "--resume"}, "test-version"); rc != 0 {
577 t.Fatalf("Run(chat --resume) rc = %d, want 0", rc)
578 }
579 if rc := Run([]string{"code", "--continue"}, "test-version"); rc != 0 {
580 t.Fatalf("Run(code --continue) rc = %d, want 0", rc)
581 }
582
583 want := [][]string{{"--resume"}, {"--continue"}}
584 if !reflect.DeepEqual(calls, want) {
585 t.Fatalf("interactive calls = %#v, want %#v", calls, want)
586 }
587 }
588
589 func TestRunMigratesLegacyConfigBeforeConfigOnlyCommands(t *testing.T) {
590 isolateCLIConfigHome(t)
591 legacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), "reasonix.toml")
592 if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
593 t.Fatal(err)
594 }
595 if err := os.WriteFile(legacyPath, []byte(`
596 default_model = "deepseek-flash"
597
598 [[plugins]]
599 name = "legacy-cli"
600 command = "legacy-bin"
601 `), 0o644); err != nil {
602 t.Fatal(err)
603 }
604
605 out := captureStdout(t, func() {
606 if rc := Run([]string{"mcp", "list"}, "test-version"); rc != 0 {
607 t.Fatalf("mcp list rc = %d, want 0", rc)
608 }
609 })
610 if !strings.Contains(out, "legacy-cli") {
611 t.Fatalf("mcp list should include migrated legacy config:\n%s", out)
612 }
613
614 body, err := os.ReadFile(config.UserConfigPath())
615 if err != nil {
616 t.Fatalf("read migrated user config: %v", err)
617 }
618 for _, want := range []string{`config_version = 5`, `[desktop]`, `name = "legacy-cli"`} {
619 if !strings.Contains(string(body), want) {
620 t.Fatalf("migrated config missing %q:\n%s", want, body)
621 }
622 }
623 }
624
625 func TestRunAppliesUserConfigUpgradesOnStartup(t *testing.T) {
626 isolateCLIConfigHome(t)
627 path := config.UserConfigPath()
628 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
629 t.Fatal(err)
630 }
631 if err := os.WriteFile(path, []byte("config_version = 2\ndefault_model = \"deepseek-flash\"\n"), 0o644); err != nil {
632 t.Fatal(err)
633 }
634
635 captureStdout(t, func() {
636 if rc := Run([]string{"mcp", "list"}, "test-version"); rc != 0 {
637 t.Fatalf("mcp list rc = %d, want 0", rc)
638 }
639 })
640
641 body, err := os.ReadFile(path)
642 if err != nil {
643 t.Fatalf("read upgraded user config: %v", err)
644 }
645 if !strings.Contains(string(body), "config_version = 5") {
646 t.Fatalf("CLI startup should apply user config upgrades:\n%s", body)
647 }
648 }
649
650 func TestRunMetadataCommandsDoNotMigrateLegacyConfig(t *testing.T) {
651 isolateCLIConfigHome(t)
652 legacyPath := filepath.Join(filepath.Dir(config.UserConfigPath()), "reasonix.toml")
653 if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
654 t.Fatal(err)
655 }
656 if err := os.WriteFile(legacyPath, []byte(`default_model = "deepseek-flash"`), 0o644); err != nil {
657 t.Fatal(err)
658 }
659
660 out := captureStdout(t, func() {
661 if rc := Run([]string{"version"}, "test-version"); rc != 0 {
662 t.Fatalf("version rc = %d, want 0", rc)
663 }
664 })
665 if !strings.Contains(out, "reasonix test-version") {
666 t.Fatalf("version output = %q", out)
667 }
668 if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
669 t.Fatalf("version should not migrate legacy config, stat err=%v", err)
670 }
671 }
672
673 func TestConfigLoadIgnoresRetiredAutoPlan(t *testing.T) {
674 isolateCLIConfigHome(t)
675 if err := os.WriteFile("reasonix.toml", []byte("[agent]\nauto_plan = \"on\"\nauto_plan_classifier = \"deepseek-flash\"\n"), 0o644); err != nil {
676 t.Fatalf("write project config: %v", err)
677 }
678
679 cfg, err := config.Load()
680 if err != nil {
681 t.Fatalf("load config: %v", err)
682 }
683 if cfg.Agent.AutoPlan != "off" || cfg.Agent.AutoPlanClassifier != "" {
684 t.Fatalf("retired auto-plan config = (%q, %q), want off/empty", cfg.Agent.AutoPlan, cfg.Agent.AutoPlanClassifier)
685 }
686 }
687
688 func TestConfigAutoPlanCompatibilityCommandKeepsOffAsNoOp(t *testing.T) {
689 isolateCLIConfigHome(t)
690 path := config.UserConfigPath()
691 cfg := config.Default()
692 cfg.Agent.Temperature = 0.4
693 if err := cfg.SaveTo(path); err != nil {
694 t.Fatalf("write user config: %v", err)
695 }
696 before, err := os.ReadFile(path)
697 if err != nil {
698 t.Fatalf("read user config before command: %v", err)
699 }
700
701 out := captureStdout(t, func() {
702 if rc := Run([]string{"config", "auto-plan", "off"}, "test-version"); rc != 0 {
703 t.Fatalf("config auto-plan off rc = %d, want 0", rc)
704 }
705 })
706 if out != "auto_plan = \"off\"\n" {
707 t.Fatalf("config auto-plan off output = %q", out)
708 }
709 after, err := os.ReadFile(path)
710 if err != nil {
711 t.Fatalf("read user config after command: %v", err)
712 }
713 if !bytes.Equal(after, before) {
714 t.Fatalf("config auto-plan off must not rewrite user config\nbefore:\n%s\nafter:\n%s", before, after)
715 }
716
717 out = captureStdout(t, func() {
718 if rc := Run([]string{"config", "auto-plan"}, "test-version"); rc != 0 {
719 t.Fatalf("config auto-plan query rc = %d, want 0", rc)
720 }
721 })
722 if out != "auto_plan = \"off\"\n" {
723 t.Fatalf("config auto-plan query output = %q", out)
724 }
725 }
726
727 func TestConfigAutoPlanCompatibilityCommandRejectsEnable(t *testing.T) {
728 isolateCLIConfigHome(t)
729
730 errOut := captureStderr(t, func() {
731 if rc := Run([]string{"config", "auto-plan", "on"}, "test-version"); rc != 2 {
732 t.Fatalf("config auto-plan on rc = %d, want 2", rc)
733 }
734 })
735 if !strings.Contains(errOut, "automatic plan mode has been retired") {
736 t.Fatalf("config auto-plan on stderr = %q", errOut)
737 }
738 }
739
740 func TestConfigReasoningLanguageCommandWritesUserConfig(t *testing.T) {
741 isolateCLIConfigHome(t)
742
743 out := captureStdout(t, func() {
744 if rc := Run([]string{"config", "reasoning-language", "zh"}, "test-version"); rc != 0 {
745 t.Fatalf("config reasoning-language rc = %d, want 0", rc)
746 }
747 })
748 if !strings.Contains(out, `reasoning_language = "zh"`) {
749 t.Fatalf("config reasoning-language output = %q", out)
750 }
751 cfg := config.LoadForEdit(config.UserConfigPath())
752 if cfg.Agent.ReasoningLanguage != "zh" || cfg.ReasoningLanguage() != "zh" {
753 t.Fatalf("saved reasoning_language = %q/%q, want zh", cfg.Agent.ReasoningLanguage, cfg.ReasoningLanguage())
754 }
755 }
756
757 func TestConfigReasoningLanguageLocalCreatesMinimalProjectOverride(t *testing.T) {
758 isolateCLIConfigHome(t)
759
760 userCfg := config.Default()
761 userCfg.DefaultModel = "mimo-pro"
762 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
763 t.Fatalf("write user config: %v", err)
764 }
765
766 out := captureStdout(t, func() {
767 if rc := Run([]string{"config", "reasoning-language", "--local", "en"}, "test-version"); rc != 0 {
768 t.Fatalf("config reasoning-language --local rc = %d, want 0", rc)
769 }
770 })
771 if !strings.Contains(out, `reasoning_language = "en"`) {
772 t.Fatalf("config reasoning-language --local output = %q", out)
773 }
774
775 body, err := os.ReadFile("reasonix.toml")
776 if err != nil {
777 t.Fatalf("read project config: %v", err)
778 }
779 if strings.Contains(string(body), "default_model") {
780 t.Fatalf("project reasoning-language override should not pin default_model:\n%s", body)
781 }
782 if !strings.Contains(string(body), "[agent]") || !strings.Contains(string(body), `reasoning_language = "en"`) {
783 t.Fatalf("project config missing reasoning_language override:\n%s", body)
784 }
785
786 cfg, err := config.Load()
787 if err != nil {
788 t.Fatalf("load merged config: %v", err)
789 }
790 if cfg.DefaultModel != "mimo-pro" {
791 t.Fatalf("default_model = %q, want global mimo-pro", cfg.DefaultModel)
792 }
793 if cfg.ReasoningLanguage() != "en" {
794 t.Fatalf("reasoning_language = %q, want local en", cfg.ReasoningLanguage())
795 }
796 }
797
798 func TestConfigReasoningLanguageRejectsAliases(t *testing.T) {
799 isolateCLIConfigHome(t)
800
801 errOut := captureStderr(t, func() {
802 if rc := Run([]string{"config", "reasoning-language", "中文"}, "test-version"); rc != 2 {
803 t.Fatalf("config reasoning-language alias rc = %d, want 2", rc)
804 }
805 })
806 if !strings.Contains(errOut, "must be auto|zh|en") {
807 t.Fatalf("config reasoning-language alias stderr = %q", errOut)
808 }
809 }
810
811 func TestConfigCompactRatioCommandWritesUserConfigAndReportsSource(t *testing.T) {
812 isolateCLIConfigHome(t)
813 userCfg := config.Default()
814 userCfg.Agent.Temperature = 0.42
815 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
816 t.Fatalf("write user config: %v", err)
817 }
818
819 out := captureStdout(t, func() {
820 if rc := Run([]string{"config", "compact-ratio", "75.5"}, "test-version"); rc != 0 {
821 t.Fatalf("config compact-ratio rc = %d, want 0", rc)
822 }
823 })
824 if !strings.Contains(out, "compact_ratio = 75.5%") || !strings.Contains(out, "user:") {
825 t.Fatalf("config compact-ratio output = %q", out)
826 }
827 cfg := config.LoadForEdit(config.UserConfigPath())
828 if got := cfg.Agent.CompactRatio; got != 0.755 {
829 t.Fatalf("saved compact ratio = %v, want 0.755", got)
830 }
831 if got := cfg.Agent.Temperature; got != 0.42 {
832 t.Fatalf("compact-ratio update changed temperature to %v, want 0.42", got)
833 }
834
835 out = captureStdout(t, func() {
836 if rc := Run([]string{"config", "compact-ratio"}, "test-version"); rc != 0 {
837 t.Fatalf("config compact-ratio query rc = %d, want 0", rc)
838 }
839 })
840 if !strings.Contains(out, "compact_ratio = 75.5%") || !strings.Contains(out, "user:") {
841 t.Fatalf("config compact-ratio query output = %q", out)
842 }
843 }
844
845 func TestConfigCompactRatioQueryReportsBuiltInDefault(t *testing.T) {
846 isolateCLIConfigHome(t)
847
848 out := captureStdout(t, func() {
849 if rc := Run([]string{"config", "compact-ratio"}, "test-version"); rc != 0 {
850 t.Fatalf("config compact-ratio query rc = %d, want 0", rc)
851 }
852 })
853 if out != "compact_ratio = 80% (built-in default)\n" {
854 t.Fatalf("config compact-ratio query output = %q", out)
855 }
856 }
857
858 func TestConfigCompactRatioLocalCreatesMinimalProjectOverride(t *testing.T) {
859 isolateCLIConfigHome(t)
860
861 userCfg := config.Default()
862 userCfg.DefaultModel = "mimo-pro"
863 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
864 t.Fatalf("write user config: %v", err)
865 }
866
867 out := captureStdout(t, func() {
868 if rc := Run([]string{"config", "compact-ratio", "--local", "70"}, "test-version"); rc != 0 {
869 t.Fatalf("config compact-ratio --local rc = %d, want 0", rc)
870 }
871 })
872 if !strings.Contains(out, "compact_ratio = 70%") || !strings.Contains(out, "project:") {
873 t.Fatalf("config compact-ratio --local output = %q", out)
874 }
875
876 body, err := os.ReadFile("reasonix.toml")
877 if err != nil {
878 t.Fatalf("read project config: %v", err)
879 }
880 if strings.Contains(string(body), "default_model") {
881 t.Fatalf("project compact-ratio override should not pin default_model:\n%s", body)
882 }
883 if !strings.Contains(string(body), "[agent]") || !strings.Contains(string(body), "compact_ratio = 0.7") {
884 t.Fatalf("project config missing compact_ratio override:\n%s", body)
885 }
886
887 cfg, err := config.Load()
888 if err != nil {
889 t.Fatalf("load merged config: %v", err)
890 }
891 if cfg.DefaultModel != "mimo-pro" {
892 t.Fatalf("default_model = %q, want global mimo-pro", cfg.DefaultModel)
893 }
894 if cfg.Agent.CompactRatio != 0.7 {
895 t.Fatalf("compact ratio = %v, want local 0.7", cfg.Agent.CompactRatio)
896 }
897
898 out = captureStdout(t, func() {
899 if rc := Run([]string{"config", "compact-ratio"}, "test-version"); rc != 0 {
900 t.Fatalf("config compact-ratio query rc = %d, want 0", rc)
901 }
902 })
903 if !strings.Contains(out, "compact_ratio = 70%") || !strings.Contains(out, "project:") {
904 t.Fatalf("project compact-ratio query output = %q", out)
905 }
906 }
907
908 func TestConfigCompactRatioRejectsValuesOutsideEditableRange(t *testing.T) {
909 isolateCLIConfigHome(t)
910
911 for _, value := range []string{"64", "86", "NaN", "+Inf", "not-a-number"} {
912 t.Run(value, func(t *testing.T) {
913 errOut := captureStderr(t, func() {
914 if rc := Run([]string{"config", "compact-ratio", value}, "test-version"); rc != 2 {
915 t.Fatalf("config compact-ratio %s rc = %d, want 2", value, rc)
916 }
917 })
918 if !strings.Contains(errOut, "percentage between 65 and 85") {
919 t.Fatalf("config compact-ratio %s stderr = %q", value, errOut)
920 }
921 })
922 }
923 if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
924 t.Fatalf("invalid compact ratio wrote user config, stat err=%v", err)
925 }
926 }
927
928 func TestConfigCurrencyCommandWritesUserConfig(t *testing.T) {
929 isolateCLIConfigHome(t)
930
931 out := captureStdout(t, func() {
932 if rc := Run([]string{"config", "currency", "CNY"}, "test-version"); rc != 0 {
933 t.Fatalf("config currency rc = %d, want 0", rc)
934 }
935 })
936 if !strings.Contains(out, `currency = "CNY"`) || !strings.Contains(out, "resolved: CNY") {
937 t.Fatalf("config currency output = %q", out)
938 }
939 cfg := config.LoadForEdit(config.UserConfigPath())
940 if got := cfg.DesktopCurrency(); got != "CNY" {
941 t.Fatalf("saved currency = %q, want CNY", got)
942 }
943 }
944
945 func TestConfigCurrencyAutoUsesResolvedCLILocale(t *testing.T) {
946 isolateCLIConfigHome(t)
947 i18n.DetectLanguage("zh-TW")
948 t.Cleanup(func() { i18n.DetectLanguage("en") })
949
950 out := captureStdout(t, func() {
951 if rc := configCurrencyCommand([]string{"auto"}); rc != 0 {
952 t.Fatalf("config currency auto rc = %d, want 0", rc)
953 }
954 })
955 if !strings.Contains(out, `currency = "auto"`) || !strings.Contains(out, "resolved: CNY") {
956 t.Fatalf("config currency auto output = %q", out)
957 }
958 cfg := config.LoadForEdit(config.UserConfigPath())
959 if got := cfg.DesktopCurrency(); got != "" {
960 t.Fatalf("auto should clear saved currency, got %q", got)
961 }
962 }
963
964 func TestConfigCurrencyRejectsProjectScope(t *testing.T) {
965 isolateCLIConfigHome(t)
966 errOut := captureStderr(t, func() {
967 if rc := Run([]string{"config", "currency", "--local", "USD"}, "test-version"); rc != 2 {
968 t.Fatalf("config currency --local rc = %d, want 2", rc)
969 }
970 })
971 if !strings.Contains(errOut, "user-level only") {
972 t.Fatalf("config currency --local stderr = %q", errOut)
973 }
974 if _, err := os.Stat("reasonix.toml"); !os.IsNotExist(err) {
975 t.Fatalf("config currency --local wrote project config, stat err=%v", err)
976 }
977 }
978
979 func TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {
980 cfg := config.Default()
981 t.Setenv("DEEPSEEK_API_KEY", "")
982 t.Setenv("MIMO_API_KEY", "")
983
984 missing := providersWithMissingKeys(cfg)
985 if len(missing) != 1 {
986 t.Fatalf("missing providers = %+v, want only active default model provider", missing)
987 }
988 if missing[0].APIKeyEnv != "DEEPSEEK_API_KEY" {
989 t.Fatalf("missing key env = %q, want DEEPSEEK_API_KEY", missing[0].APIKeyEnv)
990 }
991 }
992
993 func TestProvidersWithMissingKeysIgnoresUnusedBuiltInPresets(t *testing.T) {
994 cfg := config.Default()
995 t.Setenv("DEEPSEEK_API_KEY", "test-key")
996 t.Setenv("MIMO_API_KEY", "")
997
998 if missing := providersWithMissingKeys(cfg); len(missing) != 0 {
999 t.Fatalf("missing providers = %+v, want none when only the configured default is keyed", missing)
1000 }
1001 }
1002
1003 func TestProvidersWithMissingKeysIncludesReferencedSecondaryModels(t *testing.T) {
1004 cfg := config.Default()
1005 cfg.Providers = append(cfg.Providers,
1006 config.ProviderEntry{Name: "mimo-pro", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", APIKeyEnv: "MIMO_API_KEY"},
1007 config.ProviderEntry{Name: "mimo-flash", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5", APIKeyEnv: "MIMO_API_KEY"},
1008 )
1009 cfg.Agent.PlannerModel = "mimo-pro"
1010 cfg.Agent.SubagentModel = "mimo-flash"
1011 cfg.Agent.SubagentModels = map[string]string{
1012 "review": "mimo-pro/mimo-v2.5-pro",
1013 }
1014 t.Setenv("DEEPSEEK_API_KEY", "test-key")
1015 t.Setenv("MIMO_API_KEY", "")
1016
1017 missing := providersWithMissingKeys(cfg)
1018 if len(missing) != 1 {
1019 t.Fatalf("missing providers = %+v, want MiMo once", missing)
1020 }
1021 if missing[0].APIKeyEnv != "MIMO_API_KEY" {
1022 t.Fatalf("missing key env = %q, want MIMO_API_KEY", missing[0].APIKeyEnv)
1023 }
1024 }
1025
1026 type cliRecordSink struct {
1027 events []event.Kind
1028 }
1029
1030 func (s *cliRecordSink) Emit(e event.Event) {
1031 s.events = append(s.events, e.Kind)
1032 }
1033
1034 type cliRecordSender struct {
1035 messages []notify.Message
1036 }
1037
1038 func (s *cliRecordSender) Send(m notify.Message) error {
1039 s.messages = append(s.messages, m)
1040 return nil
1041 }
1042
1043 func TestWithNotificationsWrapsCLISinkWithConfiguredSender(t *testing.T) {
1044 inner := &cliRecordSink{}
1045 sender := &cliRecordSender{}
1046 calls := 0
1047 prev := newNotificationSender
1048 newNotificationSender = func() notify.Sender {
1049 calls++
1050 return sender
1051 }
1052 t.Cleanup(func() { newNotificationSender = prev })
1053
1054 cfg := config.Default()
1055 cfg.Notifications.Enabled = true
1056
1057 wrapped := withNotifications(inner, cfg)
1058 wrapped.Emit(event.Event{Kind: event.TurnDone})
1059
1060 if calls != 1 {
1061 t.Fatalf("newNotificationSender calls = %d, want 1", calls)
1062 }
1063 if len(inner.events) != 1 || inner.events[0] != event.TurnDone {
1064 t.Fatalf("forwarded events = %v, want [TurnDone]", inner.events)
1065 }
1066 if len(sender.messages) != 1 {
1067 t.Fatalf("notifications = %d, want 1", len(sender.messages))
1068 }
1069 if sender.messages[0].Body != "Turn finished" {
1070 t.Fatalf("notification body = %q, want Turn finished", sender.messages[0].Body)
1071 }
1072 }
1073
1074 func TestConfigTelemetryCommandRoundTripAndOptOutCleanup(t *testing.T) {
1075 isolateCLIConfigHome(t)
1076 out := captureStdout(t, func() {
1077 if rc := configTelemetryCommand(nil); rc != 0 {
1078 t.Fatalf("config telemetry query rc = %d", rc)
1079 }
1080 })
1081 if !strings.Contains(out, `cli_metrics = "auto"`) {
1082 t.Fatalf("default telemetry query = %q", out)
1083 }
1084 if rc := configTelemetryCommand([]string{"on"}); rc != 0 {
1085 t.Fatalf("config telemetry on rc = %d", rc)
1086 }
1087 cfg, err := config.Load()
1088 if err != nil || cfg.CLITelemetryMode() != "on" {
1089 t.Fatalf("saved telemetry mode = %q, err = %v", cfg.CLITelemetryMode(), err)
1090 }
1091 pending := filepath.Join(config.ReasonixHomeDir(), "cli-telemetry-pending")
1092 if err := os.MkdirAll(pending, 0o700); err != nil {
1093 t.Fatal(err)
1094 }
1095 if err := os.WriteFile(filepath.Join(pending, "pending.json"), []byte("{}"), 0o600); err != nil {
1096 t.Fatal(err)
1097 }
1098 if rc := configTelemetryCommand([]string{"off"}); rc != 0 {
1099 t.Fatalf("config telemetry off rc = %d", rc)
1100 }
1101 if _, err := os.Stat(pending); !errors.Is(err, os.ErrNotExist) {
1102 t.Fatalf("opt-out did not remove pending queue: %v", err)
1103 }
1104 }
1105
1106 func TestConfigTelemetryCommandReportsOptOutCleanupFailure(t *testing.T) {
1107 isolateCLIConfigHome(t)
1108 previous := cleanupCLITelemetry
1109 t.Cleanup(func() { cleanupCLITelemetry = previous })
1110 cleanupCLITelemetry = func(string) error { return errors.New("cleanup denied") }
1111
1112 errOut := captureStderr(t, func() {
1113 if rc := configTelemetryCommand([]string{"off"}); rc != 1 {
1114 t.Fatalf("config telemetry off rc = %d, want 1", rc)
1115 }
1116 })
1117 if !strings.Contains(errOut, "telemetry disabled") || !strings.Contains(errOut, "cleanup denied") {
1118 t.Fatalf("cleanup failure stderr = %q", errOut)
1119 }
1120 cfg, err := config.Load()
1121 if err != nil || cfg.CLITelemetryMode() != "off" {
1122 t.Fatalf("saved telemetry mode = %q, err = %v", cfg.CLITelemetryMode(), err)
1123 }
1124 }
1125
1126 func TestCLITelemetryConsentDefaultsYesAndPromptsOnlyOnce(t *testing.T) {
1127 isolateCLIConfigHome(t)
1128 clearCLITelemetryPolicyEnv(t)
1129 t.Cleanup(func() { i18n.DetectLanguage("en") })
1130 i18n.DetectLanguage("en")
1131
1132 previousStart := startCLITelemetryReporter
1133 t.Cleanup(func() { startCLITelemetryReporter = previousStart })
1134 want := &telemetry.Reporter{}
1135 starts := 0
1136 startCLITelemetryReporter = func(opts telemetry.Options) *telemetry.Reporter {
1137 starts++
1138 saved, err := config.LoadForEditReadOnlyStrict(config.UserConfigPath())
1139 if err != nil || !saved.CLITelemetryConfigured() || saved.CLITelemetryMode() != "auto" {
1140 t.Fatalf("telemetry started before consent was saved: mode=%q configured=%v err=%v", saved.CLITelemetryMode(), saved.CLITelemetryConfigured(), err)
1141 }
1142 return want
1143 }
1144
1145 cfg := config.Default()
1146 var out, errOut bytes.Buffer
1147 got := startCLITelemetryWithIO(cfg, telemetry.Options{
1148 Version: "v1.20.0", Interactive: true, CLIMode: "tui",
1149 }, strings.NewReader("\n"), &out, &errOut)
1150 if got != want || starts != 1 {
1151 t.Fatalf("first start = %p, calls=%d; want %p, 1", got, starts, want)
1152 }
1153 if !strings.Contains(out.String(), "crash.reasonix.io") || !strings.Contains(out.String(), "[Y/n]:") || !strings.Contains(out.String(), "reasonix config telemetry off") {
1154 t.Fatalf("consent prompt is incomplete: %q", out.String())
1155 }
1156 if errOut.Len() != 0 {
1157 t.Fatalf("unexpected consent stderr: %q", errOut.String())
1158 }
1159 if !cfg.CLITelemetryConfigured() || cfg.CLITelemetryMode() != "auto" {
1160 t.Fatalf("runtime config was not synchronized: mode=%q configured=%v", cfg.CLITelemetryMode(), cfg.CLITelemetryConfigured())
1161 }
1162
1163 var secondOut bytes.Buffer
1164 if got := startCLITelemetryWithIO(cfg, telemetry.Options{
1165 Version: "v1.20.0", Interactive: true, CLIMode: "tui",
1166 }, strings.NewReader("n\n"), &secondOut, &errOut); got != want {
1167 t.Fatalf("second start = %p, want %p", got, want)
1168 }
1169 if secondOut.Len() != 0 || starts != 2 {
1170 t.Fatalf("saved decision prompted again: output=%q calls=%d", secondOut.String(), starts)
1171 }
1172 }
1173
1174 func TestCLITelemetryConsentNoDisablesAndCleansPending(t *testing.T) {
1175 isolateCLIConfigHome(t)
1176 clearCLITelemetryPolicyEnv(t)
1177
1178 previousStart := startCLITelemetryReporter
1179 t.Cleanup(func() { startCLITelemetryReporter = previousStart })
1180 starts := 0
1181 startCLITelemetryReporter = func(telemetry.Options) *telemetry.Reporter {
1182 starts++
1183 return &telemetry.Reporter{}
1184 }
1185 home := config.ReasonixHomeDir()
1186 pending := filepath.Join(home, "cli-telemetry-pending")
1187 if err := os.MkdirAll(pending, 0o700); err != nil {
1188 t.Fatal(err)
1189 }
1190 if err := os.WriteFile(filepath.Join(pending, "pending.json"), []byte("{}"), 0o600); err != nil {
1191 t.Fatal(err)
1192 }
1193
1194 cfg := config.Default()
1195 var out, errOut bytes.Buffer
1196 if got := startCLITelemetryWithIO(cfg, telemetry.Options{
1197 Version: "v1.20.0", Interactive: true, CLIMode: "tui",
1198 }, strings.NewReader("n\n"), &out, &errOut); got != nil {
1199 t.Fatalf("declined telemetry returned reporter %p", got)
1200 }
1201 if starts != 0 {
1202 t.Fatalf("declined telemetry started upload %d times", starts)
1203 }
1204 if cfg.CLITelemetryMode() != "off" || !cfg.CLITelemetryConfigured() {
1205 t.Fatalf("decline was not saved in runtime config: mode=%q configured=%v", cfg.CLITelemetryMode(), cfg.CLITelemetryConfigured())
1206 }
1207 if _, err := os.Stat(pending); !errors.Is(err, os.ErrNotExist) {
1208 t.Fatalf("decline did not clear pending queue: %v", err)
1209 }
1210 saved, err := config.LoadForEditReadOnlyStrict(config.UserConfigPath())
1211 if err != nil || saved.CLITelemetryMode() != "off" || !saved.CLITelemetryConfigured() {
1212 t.Fatalf("saved decline = mode %q configured=%v err=%v", saved.CLITelemetryMode(), saved.CLITelemetryConfigured(), err)
1213 }
1214 }
1215
1216 func TestCLITelemetryConsentSaveFailureDoesNotUpload(t *testing.T) {
1217 isolateCLIConfigHome(t)
1218 clearCLITelemetryPolicyEnv(t)
1219
1220 previousSave := persistCLITelemetryConsent
1221 previousStart := startCLITelemetryReporter
1222 t.Cleanup(func() {
1223 persistCLITelemetryConsent = previousSave
1224 startCLITelemetryReporter = previousStart
1225 })
1226 persistCLITelemetryConsent = func(string) error { return errors.New("read-only config") }
1227 starts := 0
1228 startCLITelemetryReporter = func(telemetry.Options) *telemetry.Reporter {
1229 starts++
1230 return &telemetry.Reporter{}
1231 }
1232
1233 cfg := config.Default()
1234 var out, errOut bytes.Buffer
1235 if got := startCLITelemetryWithIO(cfg, telemetry.Options{
1236 Version: "v1.20.0", Interactive: true, CLIMode: "tui",
1237 }, strings.NewReader("\n"), &out, &errOut); got != nil {
1238 t.Fatalf("save failure returned reporter %p", got)
1239 }
1240 if starts != 0 || cfg.CLITelemetryConfigured() {
1241 t.Fatalf("save failure started=%d configured=%v", starts, cfg.CLITelemetryConfigured())
1242 }
1243 if !strings.Contains(errOut.String(), "read-only config") {
1244 t.Fatalf("save failure was not explained: %q", errOut.String())
1245 }
1246 }
1247
1248 func TestConfiguredCLITelemetryDoesNotPromptAgain(t *testing.T) {
1249 isolateCLIConfigHome(t)
1250 clearCLITelemetryPolicyEnv(t)
1251 previousSave := persistCLITelemetryConsent
1252 previousStart := startCLITelemetryReporter
1253 t.Cleanup(func() {
1254 persistCLITelemetryConsent = previousSave
1255 startCLITelemetryReporter = previousStart
1256 })
1257 persistCalls := 0
1258 persistCLITelemetryConsent = func(string) error {
1259 persistCalls++
1260 return nil
1261 }
1262 want := &telemetry.Reporter{}
1263 startCalls := 0
1264 startCLITelemetryReporter = func(opts telemetry.Options) *telemetry.Reporter {
1265 startCalls++
1266 if telemetry.Enabled(opts.Mode, opts.Version, opts.Interactive) {
1267 return want
1268 }
1269 return nil
1270 }
1271
1272 for _, mode := range []string{"auto", "on", "off"} {
1273 cfg := config.Default()
1274 if err := cfg.SetCLITelemetryMode(mode); err != nil {
1275 t.Fatal(err)
1276 }
1277 var out bytes.Buffer
1278 got := startCLITelemetryWithIO(cfg, telemetry.Options{
1279 Version: "v1.20.0", Interactive: true, CLIMode: "tui",
1280 }, strings.NewReader("n\n"), &out, io.Discard)
1281 if out.Len() != 0 {
1282 t.Fatalf("configured mode %q prompted again: %q", mode, out.String())
1283 }
1284 if mode == "off" && got != nil {
1285 t.Fatalf("configured off returned reporter %p", got)
1286 }
1287 if mode != "off" && got != want {
1288 t.Fatalf("configured %s returned %p, want %p", mode, got, want)
1289 }
1290 }
1291 if persistCalls != 0 || startCalls != 3 {
1292 t.Fatalf("configured modes persisted=%d started=%d, want 0 and 3", persistCalls, startCalls)
1293 }
1294 }
1295
1296 func TestUndecidedCLITelemetryDoesNotPromptOrUploadWhenIneligible(t *testing.T) {
1297 for _, tc := range []struct {
1298 name string
1299 version string
1300 interactive bool
1301 envKey string
1302 envValue string
1303 }{
1304 {name: "noninteractive", version: "v1.20.0"},
1305 {name: "development", version: "dev", interactive: true},
1306 {name: "CI", version: "v1.20.0", interactive: true, envKey: "CI", envValue: "1"},
1307 {name: "do not track", version: "v1.20.0", interactive: true, envKey: "DO_NOT_TRACK", envValue: "1"},
1308 {name: "environment opt out", version: "v1.20.0", interactive: true, envKey: "REASONIX_TELEMETRY", envValue: "0"},
1309 } {
1310 t.Run(tc.name, func(t *testing.T) {
1311 isolateCLIConfigHome(t)
1312 clearCLITelemetryPolicyEnv(t)
1313 if tc.envKey != "" {
1314 t.Setenv(tc.envKey, tc.envValue)
1315 }
1316 cfg, err := config.LoadForRootReadOnly(".")
1317 if err != nil {
1318 t.Fatal(err)
1319 }
1320 var out, errOut bytes.Buffer
1321 if got := startCLITelemetryWithIO(cfg, telemetry.Options{
1322 Version: tc.version, Interactive: tc.interactive, CLIMode: "tui",
1323 }, strings.NewReader("\n"), &out, &errOut); got != nil {
1324 t.Fatalf("ineligible telemetry returned reporter %p", got)
1325 }
1326 if out.Len() != 0 || errOut.Len() != 0 {
1327 t.Fatalf("ineligible telemetry wrote output: stdout=%q stderr=%q", out.String(), errOut.String())
1328 }
1329 if _, err := os.Stat(config.UserConfigPath()); !errors.Is(err, os.ErrNotExist) {
1330 t.Fatalf("ineligible invocation wrote config: %v", err)
1331 }
1332 })
1333 }
1334 }
1335
1336 func TestLegacySafeModeEnvDoesNotAlterConfiguredCLITelemetry(t *testing.T) {
1337 isolateCLIConfigHome(t)
1338 clearCLITelemetryPolicyEnv(t)
1339 t.Setenv("REASONIX_SAFE_MODE", "1")
1340 cfg := config.Default()
1341 if err := cfg.SetCLITelemetryMode("auto"); err != nil {
1342 t.Fatal(err)
1343 }
1344 previousStart := startCLITelemetryReporter
1345 t.Cleanup(func() { startCLITelemetryReporter = previousStart })
1346 want := &telemetry.Reporter{}
1347 startCLITelemetryReporter = func(telemetry.Options) *telemetry.Reporter { return want }
1348 if got := startCLITelemetryWithIO(cfg, telemetry.Options{
1349 Version: "v1.20.0", Interactive: true, CLIMode: "tui",
1350 }, strings.NewReader(""), io.Discard, io.Discard); got != want {
1351 t.Fatalf("telemetry reporter = %p, want %p", got, want)
1352 }
1353 }
1354
1355 func TestCLITelemetryConsentPromptIsLocalized(t *testing.T) {
1356 isolateCLIConfigHome(t)
1357 clearCLITelemetryPolicyEnv(t)
1358 previousSave := persistCLITelemetryConsent
1359 previousStart := startCLITelemetryReporter
1360 t.Cleanup(func() {
1361 persistCLITelemetryConsent = previousSave
1362 startCLITelemetryReporter = previousStart
1363 i18n.DetectLanguage("en")
1364 })
1365 persistCLITelemetryConsent = func(string) error { return nil }
1366 startCLITelemetryReporter = func(telemetry.Options) *telemetry.Reporter { return nil }
1367
1368 for _, lang := range []string{"en", "zh", "zh-TW"} {
1369 i18n.DetectLanguage(lang)
1370 var out bytes.Buffer
1371 startCLITelemetryWithIO(config.Default(), telemetry.Options{
1372 Version: "v1.20.0", Interactive: true, CLIMode: "tui",
1373 }, strings.NewReader("\n"), &out, io.Discard)
1374 for _, required := range []string{"crash.reasonix.io", "reasonix config telemetry off", "[Y/n]:"} {
1375 if !strings.Contains(out.String(), required) {
1376 t.Fatalf("%s consent prompt missing %q: %q", lang, required, out.String())
1377 }
1378 }
1379 }
1380 }
1381
1382 func clearCLITelemetryPolicyEnv(t *testing.T) {
1383 t.Helper()
1384 for _, key := range []string{
1385 "DO_NOT_TRACK", "REASONIX_TELEMETRY", "REASONIX_SAFE_MODE", "CI", "CONTINUOUS_INTEGRATION",
1386 "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI", "JENKINS_URL",
1387 "TEAMCITY_VERSION", "TF_BUILD",
1388 } {
1389 t.Setenv(key, "")
1390 }
1391 }
1392
1393 func TestSetupOverwritePromptShowsYNDefault(t *testing.T) {
1394 t.Cleanup(func() { i18n.DetectLanguage("en") })
1395 for _, lang := range []string{"en", "zh"} {
1396 i18n.DetectLanguage(lang)
1397 var out bytes.Buffer
1398 if confirmReconfigureExistingConfig("config.toml", bufio.NewScanner(strings.NewReader("\n")), &out) {
1399 t.Fatalf("%s empty overwrite answer should keep existing config", lang)
1400 }
1401 if !strings.Contains(out.String(), "[y/N]:") {
1402 t.Fatalf("%s overwrite prompt should show explicit [y/N] default, got %q", lang, out.String())
1403 }
1404 }
1405 }
1406
1407 // TestConfigureKeys verifies that a shared api_key_env (each vendor's SKUs use
1408 // the same env var) is asked only once, and entered keys become env lines.
1409 func TestConfigureKeys(t *testing.T) {
1410 // Force a clean baseline: any DEEPSEEK_API_KEY in the
1411 // process env (e.g. inherited from the test runner) would be picked up
1412 // by the new "reuse existing" path and the prompt would be skipped,
1413 // making the assertion below noisy.
1414 t.Setenv("DEEPSEEK_API_KEY", "")
1415
1416 selected := config.Default().Providers
1417
1418 input := "ds-key\n"
1419 env := configureKeys(selected, strings.NewReader(input), io.Discard)
1420
1421 if len(env) != 1 {
1422 t.Fatalf("env = %v (want 1: DeepSeek asked once)", env)
1423 }
1424 if env[0] != "DEEPSEEK_API_KEY=ds-key" {
1425 t.Errorf("env[0] = %q", env[0])
1426 }
1427 }
1428
1429 // TestConfigureKeysReusesExistingEnv covers the "user already typed the key
1430 // in the URL-fetch flow, don't ask again" path. When the env var is set
1431 // (either from .env or from a prior os.Setenv in the wizard), configureKeys
1432 // must NOT consume from the input stream — otherwise the user's next typed
1433 // line bleeds into the next provider's prompt. It also must include the
1434 // existing value in envLines so the value is re-pinned into .env on
1435 // re-runs of setup.
1436 func TestConfigureKeysReusesExistingEnv(t *testing.T) {
1437 t.Setenv("DEEPSEEK_API_KEY", "preset-ds-key")
1438
1439 selected := config.Default().Providers
1440 var output bytes.Buffer
1441 env := configureKeys(selected, strings.NewReader("\n"), &output)
1442
1443 if len(env) != 1 {
1444 t.Fatalf("env = %v (want 1: DeepSeek reused)", env)
1445 }
1446 if env[0] != "DEEPSEEK_API_KEY=preset-ds-key" {
1447 t.Errorf("env[0] = %q, want re-pinned existing value", env[0])
1448 }
1449 if !strings.Contains(output.String(), "DEEPSEEK_API_KEY") {
1450 t.Errorf("expected a 'reusing' confirmation for DEEPSEEK_API_KEY, got:\n%s", output.String())
1451 }
1452 }
1453
1454 func TestConfigureKeysCanResetExistingEnv(t *testing.T) {
1455 t.Setenv("DEEPSEEK_API_KEY", "stale-ds-key")
1456
1457 selected := config.Default().Providers
1458 var output bytes.Buffer
1459 env := configureKeys(selected, strings.NewReader("y\nfresh-ds-key\n"), &output)
1460
1461 if len(env) != 1 {
1462 t.Fatalf("env = %v (want 1: DeepSeek reset)", env)
1463 }
1464 if env[0] != "DEEPSEEK_API_KEY=fresh-ds-key" {
1465 t.Errorf("env[0] = %q, want freshly entered value", env[0])
1466 }
1467 if !strings.Contains(output.String(), "[y/N]:") || !strings.Contains(output.String(), "DEEPSEEK_API_KEY") {
1468 t.Errorf("expected a reset confirmation for DEEPSEEK_API_KEY, got:\n%s", output.String())
1469 }
1470 }
1471
1472 // TestConfigureKeysAllSetDefaultsToReusingInput ensures that when every env var
1473 // is already populated, pressing Enter at each confirmation keeps the values.
1474 func TestConfigureKeysAllSetDefaultsToReusingInput(t *testing.T) {
1475 t.Setenv("DEEPSEEK_API_KEY", "ds")
1476
1477 selected := config.Default().Providers
1478 env := configureKeys(selected, strings.NewReader("\n"), io.Discard)
1479 if len(env) != 1 {
1480 t.Errorf("env = %v, want 1 (DeepSeek reused)", env)
1481 }
1482 }
1483
1484 // TestAppendEnvUpsertReplacesExistingKey covers the bug where re-running the
1485 // wizard with a corrected key would append a second line for the same env
1486 // var. Without dedupe, different dotenv readers can disagree on which
1487 // assignment wins, leaving stale keys hard to diagnose.
1488 func TestAppendEnvUpsertReplacesExistingKey(t *testing.T) {
1489 t.Setenv("DEEPSEEK_API_KEY", "") // also covers the os.Setenv pin path
1490 p := filepath.Join(t.TempDir(), ".env")
1491 os.WriteFile(p, []byte("# initial\nDEEPSEEK_API_KEY=stale\nMIMO_API_KEY=keepme\n"), 0o600)
1492
1493 if err := appendEnv(p, []string{"DEEPSEEK_API_KEY=fresh"}); err != nil {
1494 t.Fatalf("appendEnv: %v", err)
1495 }
1496 got, _ := os.ReadFile(p)
1497 want := "# initial\nMIMO_API_KEY=keepme\nDEEPSEEK_API_KEY=fresh\n"
1498 if string(got) != want {
1499 t.Errorf("after upsert =\n%s\nwant =\n%s", got, want)
1500 }
1501 if got := os.Getenv("DEEPSEEK_API_KEY"); got != "fresh" {
1502 t.Errorf("process env DEEPSEEK_API_KEY = %q, want %q (upsert should pin in-process)", got, "fresh")
1503 }
1504 }
1505
1506 // TestAppendEnvUpsertHandlesExportPrefix proves `export FOO=...` style lines
1507 // also get replaced, since users might hand-edit .env in shell-friendly form.
1508 func TestAppendEnvUpsertHandlesExportPrefix(t *testing.T) {
1509 t.Setenv("FOO", "")
1510 p := filepath.Join(t.TempDir(), ".env")
1511 os.WriteFile(p, []byte("export FOO=old\nKEEP=yes\n"), 0o600)
1512 if err := appendEnv(p, []string{"FOO=new"}); err != nil {
1513 t.Fatalf("appendEnv: %v", err)
1514 }
1515 got, _ := os.ReadFile(p)
1516 if !strings.Contains(string(got), "FOO=new") || strings.Contains(string(got), "FOO=old") {
1517 t.Errorf("export-prefixed line not replaced:\n%s", got)
1518 }
1519 }
1520
1521 // TestGroupByFamily verifies the wizard groups the default preset into
1522 // "deepseek" (flash + pro), preserving the order each family first appears in.
1523 func TestGroupByFamily(t *testing.T) {
1524 order, members, info := groupByFamily(config.Default().Providers)
1525
1526 if got := order; !reflect.DeepEqual(got, []string{"deepseek"}) {
1527 t.Fatalf("family order = %v, want [deepseek]", got)
1528 }
1529 if got := members["deepseek"]; !reflect.DeepEqual(got, []int{0, 1}) {
1530 t.Errorf("deepseek members = %v, want [0 1]", got)
1531 }
1532 if info["deepseek"].name != "DeepSeek" {
1533 t.Errorf("display name = %q", info["deepseek"].name)
1534 }
1535 }
1536
1537 // TestFetchOrFallbackLiveReturns covers the happy path: a live /models call
1538 // succeeds and its result wins over the preset's static list. We can't run
1539 // the real probe (no key) so the FetchModels call is expected to 401 and the
1540 // fallback path runs; the assertion below is that fallback works (static
1541 // list returned) and that an empty base URL short-circuits to the static
1542 // list with no network call.
1543 func TestFetchOrFallback(t *testing.T) {
1544 t.Run("empty base URL returns static list", func(t *testing.T) {
1545 probe := config.ProviderEntry{
1546 BaseURL: "",
1547 Models: []string{"preset-a", "preset-b"},
1548 }
1549 got := fetchOrFallback(&probe, "Test")
1550 if !reflect.DeepEqual(got, []string{"preset-a", "preset-b"}) {
1551 t.Errorf("got %v, want preset-a/b", got)
1552 }
1553 })
1554
1555 t.Run("no key set returns static list (offline first-run)", func(t *testing.T) {
1556 t.Setenv("REASONIX_FETCH_TEST_KEY", "")
1557 probe := config.ProviderEntry{
1558 BaseURL: "http://127.0.0.1:1", // unreachable, no listener
1559 APIKeyEnv: "REASONIX_FETCH_TEST_KEY",
1560 Models: []string{"preset-a"},
1561 }
1562 got := fetchOrFallback(&probe, "Test")
1563 if !reflect.DeepEqual(got, []string{"preset-a"}) {
1564 t.Errorf("got %v, want preset-a", got)
1565 }
1566 })
1567 }
1568
1569 // TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider
1570 // model probe. Previously the probe was a single URL (baseURL+"/models"),
1571 // which worked for OpenAI vendors with a /v1 base URL but silently failed
1572 // for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies
1573 // (a /v1 base URL but a /v1/messages endpoint). The new helper walks
1574 // BuildModelFetchURLs's candidate list — root + /v1 + known compat
1575 // suffixes — so the same probe now succeeds for both shapes, matching
1576 // what the conversation-time client URL will actually be.
1577 func TestFetchModelListCompatWalksCandidates(t *testing.T) {
1578 t.Run("anthropic root form resolves via v1 fallback", func(t *testing.T) {
1579 var gotPath atomic.Value
1580 gotPath.Store("")
1581 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1582 gotPath.Store(r.URL.Path)
1583 if r.URL.Path == "/v1/models" {
1584 w.Header().Set("Content-Type", "application/json")
1585 _, _ = io.WriteString(w, `{"data":[{"id":"claude-test"}]}`)
1586 return
1587 }
1588 w.WriteHeader(http.StatusNotFound)
1589 }))
1590 defer srv.Close()
1591
1592 models, err := fetchModelListCompat(context.Background(), srv.URL, "k")
1593 if err != nil {
1594 t.Fatalf("fetchModelListCompat: %v", err)
1595 }
1596 if !reflect.DeepEqual(models, []string{"claude-test"}) {
1597 t.Errorf("models = %v, want [claude-test]", models)
1598 }
1599 if got := gotPath.Load().(string); got != "/v1/models" {
1600 t.Errorf("probe path = %q, want /v1/models (root form should fall through to v1 candidate)", got)
1601 }
1602 })
1603
1604 t.Run("versioned v1 base URL hits models directly", func(t *testing.T) {
1605 var gotPath atomic.Value
1606 gotPath.Store("")
1607 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1608 gotPath.Store(r.URL.Path)
1609 w.Header().Set("Content-Type", "application/json")
1610 _, _ = io.WriteString(w, `{"data":[{"id":"model-a"}]}`)
1611 }))
1612 defer srv.Close()
1613
1614 models, err := fetchModelListCompat(context.Background(), srv.URL+"/v1", "k")
1615 if err != nil {
1616 t.Fatalf("fetchModelListCompat: %v", err)
1617 }
1618 if !reflect.DeepEqual(models, []string{"model-a"}) {
1619 t.Errorf("models = %v, want [model-a]", models)
1620 }
1621 if got := gotPath.Load().(string); got != "/v1/models" {
1622 t.Errorf("probe path = %q, want /v1/models", got)
1623 }
1624 })
1625
1626 t.Run("endpoint-miss on every candidate returns empty (manual flow)", func(t *testing.T) {
1627 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1628 w.WriteHeader(http.StatusNotFound)
1629 }))
1630 defer srv.Close()
1631
1632 models, err := fetchModelListCompat(context.Background(), srv.URL, "k")
1633 if err != nil {
1634 t.Fatalf("expected graceful empty result on all-miss, got err: %v", err)
1635 }
1636 if len(models) != 0 {
1637 t.Errorf("expected empty models on all-miss, got %v", models)
1638 }
1639 })
1640
1641 t.Run("non-404 network error short-circuits with the real error", func(t *testing.T) {
1642 // Point at a closed port — connection refused, not a 404.
1643 models, err := fetchModelListCompat(context.Background(), "http://127.0.0.1:1", "k")
1644 if err == nil {
1645 t.Fatalf("expected error for unreachable host, got models=%v", models)
1646 }
1647 })
1648 }
1649
1650 // TestFamilyStaticModels proves the offline fallback unions every member of a
1651 // family (the flash + pro SKUs), not just the first — the regression that left
1652 // users with only flash when the live /models probe failed.
1653 func TestFamilyStaticModels(t *testing.T) {
1654 providers := []config.ProviderEntry{
1655 {Name: "deepseek-flash", Model: "deepseek-v4-flash"},
1656 {Name: "deepseek-pro", Model: "deepseek-v4-pro"},
1657 {Name: "mimo-flash", Model: "mimo-v2.5"},
1658 }
1659 got := familyStaticModels(providers, []int{0, 1})
1660 want := []string{"deepseek-v4-flash", "deepseek-v4-pro"}
1661 if !reflect.DeepEqual(got, want) {
1662 t.Errorf("got %v, want %v", got, want)
1663 }
1664 }
1665
1666 func TestFamilyStaticModelsDedupes(t *testing.T) {
1667 providers := []config.ProviderEntry{
1668 {Name: "a", Models: []string{"x", "y"}},
1669 {Name: "b", Models: []string{"y", "z"}},
1670 }
1671 got := familyStaticModels(providers, []int{0, 1})
1672 if !reflect.DeepEqual(got, []string{"x", "y", "z"}) {
1673 t.Errorf("got %v, want x/y/z deduped", got)
1674 }
1675 }
1676
1677 // TestBuildFamilyEntriesSplitsPricing proves flash and pro land in separate
1678 // entries carrying their own price, rather than collapsing into one entry that
1679 // would bill pro at flash's rate.
1680 func TestBuildFamilyEntriesSplitsPricing(t *testing.T) {
1681 flash := config.ProviderEntry{Name: "deepseek-flash", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Price: &provider.Pricing{Input: 1, Output: 2}}
1682 pro := config.ProviderEntry{Name: "deepseek-pro", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", Price: &provider.Pricing{Input: 3, Output: 6}}
1683 got := buildFamilyEntries(flash, []config.ProviderEntry{flash, pro}, []string{"deepseek-v4-flash", "deepseek-v4-pro"})
1684 if len(got) != 2 {
1685 t.Fatalf("got %d entries, want 2", len(got))
1686 }
1687 byName := map[string]config.ProviderEntry{}
1688 for _, e := range got {
1689 byName[e.Name] = e
1690 }
1691 if e := byName["deepseek-flash"]; e.Model != "deepseek-v4-flash" || e.Price == nil || e.Price.Output != 2 {
1692 t.Errorf("flash entry wrong: %+v (price %+v)", e, e.Price)
1693 }
1694 if e := byName["deepseek-pro"]; e.Model != "deepseek-v4-pro" || e.Price == nil || e.Price.Output != 6 {
1695 t.Errorf("pro entry wrong: %+v (price %+v)", e, e.Price)
1696 }
1697 }
1698
1699 // TestBuildFamilyEntriesUnknownModelUsesProbe puts a live-only SKU (no matching
1700 // preset) under the probe entry rather than dropping it.
1701 func TestBuildFamilyEntriesUnknownModelUsesProbe(t *testing.T) {
1702 flash := config.ProviderEntry{Name: "deepseek-flash", Model: "deepseek-v4-flash", Price: &provider.Pricing{Input: 1}}
1703 got := buildFamilyEntries(flash, []config.ProviderEntry{flash}, []string{"deepseek-v4-flash", "deepseek-v9-experimental"})
1704 if len(got) != 1 || got[0].Name != "deepseek-flash" {
1705 t.Fatalf("got %+v, want one deepseek-flash entry", got)
1706 }
1707 if !reflect.DeepEqual(got[0].Models, []string{"deepseek-v4-flash", "deepseek-v9-experimental"}) {
1708 t.Errorf("Models = %v, want both under the probe entry", got[0].Models)
1709 }
1710 }
1711
1712 // TestBuildFamilyEntry covers the three observable behaviors:
1713 // - The selected models land in the entry's Models field, with Model
1714 // pointed at the first one so legacy single-model lookups still work.
1715 // - A preset Default that points to a model the user didn't pick is
1716 // reset to the first selected model (otherwise resolve-by-default
1717 // would silently break).
1718 // - A preset Default that IS in the selection is preserved.
1719 func TestBuildFamilyEntry(t *testing.T) {
1720 t.Run("default reset when not in selection", func(t *testing.T) {
1721 probe := config.ProviderEntry{
1722 Name: "deepseek", Kind: "openai",
1723 BaseURL: "https://api.deepseek.com",
1724 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
1725 Default: "deepseek-v4-pro",
1726 }
1727 got := buildFamilyEntry(probe, []string{"deepseek-v4-flash"})
1728 if got.Model != "deepseek-v4-flash" {
1729 t.Errorf("Model = %q, want deepseek-v4-flash", got.Model)
1730 }
1731 if got.Default != "deepseek-v4-flash" {
1732 t.Errorf("Default = %q, want reset to first selected", got.Default)
1733 }
1734 if !reflect.DeepEqual(got.Models, []string{"deepseek-v4-flash"}) {
1735 t.Errorf("Models = %v", got.Models)
1736 }
1737 if got.BaseURL != "https://api.deepseek.com" {
1738 t.Errorf("BaseURL lost: %q", got.BaseURL)
1739 }
1740 })
1741
1742 t.Run("default preserved when in selection", func(t *testing.T) {
1743 probe := config.ProviderEntry{
1744 Name: "deepseek", Default: "deepseek-v4-pro",
1745 BaseURL: "https://api.deepseek.com",
1746 }
1747 got := buildFamilyEntry(probe, []string{"deepseek-v4-flash", "deepseek-v4-pro"})
1748 if got.Default != "deepseek-v4-pro" {
1749 t.Errorf("Default = %q, want preserved", got.Default)
1750 }
1751 })
1752
1753 t.Run("empty default filled from first selected", func(t *testing.T) {
1754 probe := config.ProviderEntry{Name: "x", BaseURL: "u"}
1755 got := buildFamilyEntry(probe, []string{"alpha", "beta"})
1756 if got.Default != "alpha" {
1757 t.Errorf("Default = %q, want alpha", got.Default)
1758 }
1759 })
1760 }
1761
1762 // TestProviderSlug covers the host-derivation rules and the sha1 fallback
1763 // for unparseable URLs. The exact format isn't load-bearing — what matters
1764 // is that the slug (a) starts with the kind prefix, (b) is stable across
1765 // calls with the same URL, and (c) never produces the bare "custom" /
1766 // "anthropic" magic names that would collide with the wizard menu items.
1767 func TestProviderSlug(t *testing.T) {
1768 cases := []struct {
1769 name, kind, url, want string
1770 }{
1771 {"standard host with port", "custom", "https://token.sensenova.cn/v1", "custom-token-sensenova-cn"},
1772 {"api subdomain", "custom", "https://api.openai.com/v1", "custom-api-openai-com"},
1773 {"www stripped", "custom", "https://www.example.com/v1", "custom-example-com"},
1774 {"port preserved", "custom", "http://localhost:11434/v1", "custom-localhost-11434"},
1775 {"anthropic kind", "anthropic", "https://api.anthropic.com", "anthropic-api-anthropic-com"},
1776 }
1777 for _, tc := range cases {
1778 t.Run(tc.name, func(t *testing.T) {
1779 if got := providerSlug(tc.kind, tc.url); got != tc.want {
1780 t.Errorf("providerSlug(%q, %q) = %q, want %q", tc.kind, tc.url, got, tc.want)
1781 }
1782 })
1783 }
1784
1785 t.Run("stable across calls", func(t *testing.T) {
1786 a := providerSlug("custom", "https://token.sensenova.cn/v1")
1787 b := providerSlug("custom", "https://token.sensenova.cn/v1")
1788 if a != b {
1789 t.Errorf("not stable: %q vs %q", a, b)
1790 }
1791 if a == "custom" {
1792 t.Error("slug degenerated to bare magic name — collision risk")
1793 }
1794 })
1795
1796 t.Run("sha1 fallback for unparseable URL", func(t *testing.T) {
1797 got := providerSlug("custom", "://not a url::://")
1798 if !strings.HasPrefix(got, "custom-") || got == "custom" {
1799 t.Errorf("fallback slug = %q, want custom-<hex>", got)
1800 }
1801 // sha1 is 40 hex chars; we take 4 bytes (8 hex chars).
1802 if len(got) != len("custom-")+8 {
1803 t.Errorf("fallback slug = %q, want 8 hex chars after prefix", got)
1804 }
1805 })
1806
1807 t.Run("sha1 fallback for non-ascii host", func(t *testing.T) {
1808 got := providerSlug("custom", "https://例子.测试/v1")
1809 if !strings.HasPrefix(got, "custom-") || got == "custom-" {
1810 t.Errorf("fallback slug = %q, want custom-<hex>", got)
1811 }
1812 if len(got) != len("custom-")+8 {
1813 t.Errorf("fallback slug = %q, want 8 hex chars after prefix", got)
1814 }
1815 })
1816 }
1817
1818 func TestAPIKeyEnvFromProviderName(t *testing.T) {
1819 cases := []struct {
1820 name, providerName, want string
1821 }{
1822 {"custom host slug", "custom-token-sensenova-cn", "CUSTOM_TOKEN_SENSENOVA_CN_API_KEY"},
1823 {"localhost slug with port", "custom-localhost-11434", "CUSTOM_LOCALHOST_11434_API_KEY"},
1824 {"desktop-style custom name", "Local Gateway", "LOCAL_GATEWAY_API_KEY"},
1825 {"digit-leading provider name", "9router", "CUSTOM_9ROUTER_API_KEY"},
1826 }
1827 for _, tc := range cases {
1828 t.Run(tc.name, func(t *testing.T) {
1829 if got := apiKeyEnvFromProviderName(tc.providerName); got != tc.want {
1830 t.Errorf("apiKeyEnvFromProviderName(%q) = %q, want %q", tc.providerName, got, tc.want)
1831 }
1832 })
1833 }
1834
1835 t.Run("non-ascii provider names use desktop-compatible hash fallback", func(t *testing.T) {
1836 if got, want := apiKeyEnvFromProviderName("商汤"), "CUSTOM_d39b9067_API_KEY"; got != want {
1837 t.Errorf("apiKeyEnvFromProviderName(non-ascii) = %q, want %q", got, want)
1838 }
1839 if got := apiKeyEnvFromProviderName("通义千问"); got == "CUSTOM_d39b9067_API_KEY" || got == "CUSTOM_API_KEY" {
1840 t.Errorf("apiKeyEnvFromProviderName(second non-ascii) = %q, want distinct stable fallback", got)
1841 }
1842 })
1843 }
1844
1845 func TestPromptCustomProviderManualDefaultsKeyEnvFromBaseURL(t *testing.T) {
1846 result, err := promptCustomProviderManualWith(
1847 bufio.NewScanner(strings.NewReader("sensenova-chat\n\n\n")),
1848 "https://token.sensenova.cn/v1",
1849 "",
1850 "",
1851 )
1852 if err != nil {
1853 t.Fatalf("promptCustomProviderManualWith: %v", err)
1854 }
1855 entries := result.entries
1856 if len(entries) != 1 {
1857 t.Fatalf("entries = %d, want 1", len(entries))
1858 }
1859 if got, want := entries[0].APIKeyEnv, "CUSTOM_TOKEN_SENSENOVA_CN_API_KEY"; got != want {
1860 t.Errorf("APIKeyEnv = %q, want %q", got, want)
1861 }
1862 }
1863
1864 func TestPromptCustomProviderManualPreservesExplicitKeyEnv(t *testing.T) {
1865 result, err := promptCustomProviderManualWith(
1866 bufio.NewScanner(strings.NewReader("manual-chat\n\n")),
1867 "https://token.sensenova.cn/v1",
1868 "CUSTOM_API_KEY",
1869 "",
1870 )
1871 if err != nil {
1872 t.Fatalf("promptCustomProviderManualWith: %v", err)
1873 }
1874 entries := result.entries
1875 if len(entries) != 1 {
1876 t.Fatalf("entries = %d, want 1", len(entries))
1877 }
1878 if got := entries[0].APIKeyEnv; got != "CUSTOM_API_KEY" {
1879 t.Errorf("APIKeyEnv = %q, want explicit CUSTOM_API_KEY", got)
1880 }
1881 }
1882
1883 func TestPromptAPIKeyEnvNameRejectsModelName(t *testing.T) {
1884 i18n.DetectLanguage("en")
1885 var out bytes.Buffer
1886 got := promptAPIKeyEnvName(
1887 bufio.NewScanner(strings.NewReader("grok-4.5\n\n")),
1888 &out,
1889 i18n.M.CustomPromptKeyEnv,
1890 "CUSTOM_API_YAIROUTER_COM_API_KEY",
1891 )
1892 if got != "CUSTOM_API_YAIROUTER_COM_API_KEY" {
1893 t.Fatalf("key env = %q, want generated default", got)
1894 }
1895 if text := out.String(); !strings.Contains(text, "not a valid API Key variable name") || !strings.Contains(text, "do not enter a model name") {
1896 t.Fatalf("validation guidance missing from prompt output: %q", text)
1897 }
1898 }
1899
1900 func TestPromptCustomProviderManualAsksForModelBeforeCredentialName(t *testing.T) {
1901 result, err := promptCustomProviderManualWith(
1902 bufio.NewScanner(strings.NewReader("grok-4.5\ngrok-4.5\n\n\n")),
1903 "https://api.example.com/v1",
1904 "",
1905 "",
1906 )
1907 if err != nil {
1908 t.Fatalf("promptCustomProviderManualWith: %v", err)
1909 }
1910 entries := result.entries
1911 if got := entries[0].Model; got != "grok-4.5" {
1912 t.Fatalf("model = %q, want grok-4.5", got)
1913 }
1914 if got := entries[0].APIKeyEnv; got != "CUSTOM_API_EXAMPLE_COM_API_KEY" {
1915 t.Fatalf("APIKeyEnv = %q, want generated default after invalid model-like input", got)
1916 }
1917 }
1918
1919 func TestPromptCustomProviderStagesExplicitKeyEvenWhenProcessEnvMatches(t *testing.T) {
1920 const key = "CUSTOM_API_EXAMPLE_COM_API_KEY"
1921 t.Setenv(key, "same-secret")
1922 result, err := promptCustomProviderManualWith(
1923 bufio.NewScanner(strings.NewReader("grok-4.5\n")),
1924 "https://api.example.com/v1",
1925 key,
1926 "same-secret",
1927 )
1928 if err != nil {
1929 t.Fatalf("promptCustomProviderManualWith: %v", err)
1930 }
1931 if got := result.credentials[key]; got != "same-secret" {
1932 t.Fatalf("staged credential = %q, want explicitly entered value", got)
1933 }
1934 if got := os.Getenv(key); got != "same-secret" {
1935 t.Fatalf("prompt changed process environment to %q", got)
1936 }
1937 result, err = promptCustomProviderManualWith(
1938 bufio.NewScanner(strings.NewReader("grok-4.5\n")),
1939 "https://api.example.com/v1",
1940 key,
1941 "new-secret",
1942 )
1943 if err != nil {
1944 t.Fatalf("promptCustomProviderManualWith with replacement key: %v", err)
1945 }
1946 if got := result.credentials[key]; got != "new-secret" {
1947 t.Fatalf("replacement staged credential = %q", got)
1948 }
1949 if got := os.Getenv(key); got != "same-secret" {
1950 t.Fatalf("prompt leaked replacement credential into process environment: %q", got)
1951 }
1952 }
1953
1954 func TestRepairInvalidProviderKeyEnvs(t *testing.T) {
1955 original := []config.ProviderEntry{
1956 {Name: "custom-relay-example-com", APIKeyEnv: "grok-4.5"},
1957 {Name: "valid", APIKeyEnv: "VALID_API_KEY"},
1958 {Name: "no-auth"},
1959 }
1960 got, repairs := repairInvalidProviderKeyEnvs(original)
1961 if len(repairs) != 1 {
1962 t.Fatalf("repairs = %+v, want one", repairs)
1963 }
1964 if got[0].APIKeyEnv != "CUSTOM_RELAY_EXAMPLE_COM_API_KEY" {
1965 t.Fatalf("repaired key env = %q", got[0].APIKeyEnv)
1966 }
1967 if repairs[0].old != "grok-4.5" || repairs[0].new != got[0].APIKeyEnv {
1968 t.Fatalf("repair detail = %+v", repairs[0])
1969 }
1970 if got[1].APIKeyEnv != "VALID_API_KEY" || got[2].APIKeyEnv != "" {
1971 t.Fatalf("valid/no-auth providers changed: %+v", got)
1972 }
1973 if original[0].APIKeyEnv != "grok-4.5" {
1974 t.Fatalf("repair mutated caller input: %+v", original[0])
1975 }
1976 }
1977
1978 // TestFilterStaleCustomEntries covers the wizard's auto-cleanup of legacy
1979 // "custom" / "anthropic" magic-name entries that previous versions wrote
1980 // into reasonix.toml. These collide with the wizard's own menu items, so
1981 // they're dropped from the providers list before grouping — but the caller
1982 // still gets them back in the dropped slice to surface a warning.
1983 func TestFilterStaleCustomEntries(t *testing.T) {
1984 in := []config.ProviderEntry{
1985 {Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com"},
1986 {Name: "custom", Kind: "openai", BaseURL: "https://old.example/v1"}, // stale
1987 {Name: "anthropic", Kind: "anthropic", BaseURL: "https://old.example/v1/messages"}, // stale
1988 {Name: "mimo-tp", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1"},
1989 }
1990 kept, dropped := filterStaleCustomEntries(in)
1991 if len(kept) != 2 {
1992 t.Errorf("kept = %d entries, want 2: %+v", len(kept), kept)
1993 }
1994 if len(dropped) != 2 {
1995 t.Errorf("dropped = %d entries, want 2: %+v", len(dropped), dropped)
1996 }
1997 for _, k := range kept {
1998 if k.Name == "custom" || k.Name == "anthropic" {
1999 t.Errorf("magic name leaked through: %q", k.Name)
2000 }
2001 }
2002
2003 t.Run("non-magic names with kind anthropic are kept", func(t *testing.T) {
2004 // An entry someone deliberately named "claude" (kind=anthropic) must
2005 // not be touched by the filter — only the bare "anthropic" magic name.
2006 in := []config.ProviderEntry{
2007 {Name: "claude", Kind: "anthropic", BaseURL: "https://api.anthropic.com"},
2008 }
2009 kept, dropped := filterStaleCustomEntries(in)
2010 if len(kept) != 1 || len(dropped) != 0 {
2011 t.Errorf("claude should be kept, got kept=%d dropped=%d", len(kept), len(dropped))
2012 }
2013 })
2014
2015 t.Run("custom kind anthropic is kept", func(t *testing.T) {
2016 // Name="custom" with kind=anthropic is ambiguous — keep it.
2017 in := []config.ProviderEntry{
2018 {Name: "custom", Kind: "anthropic", BaseURL: "https://x"},
2019 }
2020 kept, dropped := filterStaleCustomEntries(in)
2021 if len(kept) != 1 || len(dropped) != 0 {
2022 t.Errorf("custom+anthropic should be kept (ambiguous), got kept=%d dropped=%d", len(kept), len(dropped))
2023 }
2024 })
2025 }
2026
2027 func TestWithBuiltinFamiliesDoesNotAddMissingMimo(t *testing.T) {
2028 // The user's case: a reasonix.toml that defines only deepseek providers.
2029 cfg := []config.ProviderEntry{
2030 {Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com"},
2031 {Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com"},
2032 }
2033 order, _, info := groupByFamily(withBuiltinFamilies(cfg))
2034 seen := map[string]bool{}
2035 for _, k := range order {
2036 seen[info[k].name] = true
2037 }
2038 if !seen["DeepSeek"] {
2039 t.Fatalf("wizard families = %v, want DeepSeek", order)
2040 }
2041 if seen["MiMo (Xiaomi)"] {
2042 t.Fatalf("wizard families = %v, should not inject MiMo", order)
2043 }
2044 // A user's customized deepseek must not be duplicated.
2045 if n := len(groupByFamilyKeys(withBuiltinFamilies(cfg), "deepseek")); n != 2 {
2046 t.Fatalf("deepseek members = %d, want the user's 2 (no injected duplicate)", n)
2047 }
2048 }
2049
2050 func TestWithBuiltinFamiliesForLanguageUsesDeepSeekPricing(t *testing.T) {
2051 providers := withBuiltinFamiliesForLanguage(nil, "zh")
2052 var flash *config.ProviderEntry
2053 for i := range providers {
2054 if providers[i].Name == "deepseek-flash" {
2055 flash = &providers[i]
2056 break
2057 }
2058 }
2059 if flash == nil {
2060 t.Fatal("deepseek-flash provider missing")
2061 }
2062 if flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
2063 t.Fatalf("flash price = %+v, want CNY preset", flash.Price)
2064 }
2065 }
2066
2067 // TestWithBuiltinFamiliesRestoresSiblingEntries covers the re-run scenario:
2068 // a user previously selected only deepseek-v4-flash (saved as deepseek-flash
2069 // with a single model). Re-running `reasonix setup` must still surface the
2070 // sibling deepseek-pro entry so the user can pick deepseek-v4-pro too,
2071 // rather than only showing the previously selected model.
2072 func TestWithBuiltinFamiliesRestoresSiblingEntries(t *testing.T) {
2073 cfg := []config.ProviderEntry{
2074 {Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Models: []string{"deepseek-v4-flash"}, APIKeyEnv: "DEEPSEEK_API_KEY"},
2075 }
2076 got := withBuiltinFamilies(cfg)
2077
2078 // deepseek-pro must be restored even though deepseek family already exists.
2079 var found bool
2080 for _, p := range got {
2081 if p.Name == "deepseek-pro" {
2082 found = true
2083 break
2084 }
2085 }
2086 if !found {
2087 t.Fatalf("withBuiltinFamilies(%+v) = %v, want deepseek-pro sibling restored", cfg, namesOf(got))
2088 }
2089
2090 // The static model list for the deepseek family must include both SKUs.
2091 _, members, _ := groupByFamily(got)
2092 deepseekIdxs := members["deepseek"]
2093 models := familyStaticModels(got, deepseekIdxs)
2094 wantModels := map[string]bool{"deepseek-v4-flash": true, "deepseek-v4-pro": true}
2095 for _, m := range models {
2096 delete(wantModels, m)
2097 }
2098 if len(wantModels) > 0 {
2099 t.Errorf("familyStaticModels = %v, missing %v", models, wantModels)
2100 }
2101 }
2102
2103 func namesOf(ps []config.ProviderEntry) []string {
2104 out := make([]string, len(ps))
2105 for i, p := range ps {
2106 out[i] = p.Name
2107 }
2108 return out
2109 }
2110
2111 func groupByFamilyKeys(ps []config.ProviderEntry, key string) []int {
2112 _, members, _ := groupByFamily(ps)
2113 return members[key]
2114 }
2115
2116 func TestWriteDefaultConfigOmitsLegacyInternalMCPSections(t *testing.T) {
2117 path := filepath.Join(t.TempDir(), "reasonix.toml")
2118 if rc := writeDefaultConfig(path); rc != 0 {
2119 t.Fatalf("writeDefaultConfig rc = %d", rc)
2120 }
2121 raw, err := os.ReadFile(path)
2122 if err != nil {
2123 t.Fatal(err)
2124 }
2125 text := string(raw)
2126 for _, forbidden := range []string{"[codegraph]", "[builtin_mcp]", "[builtin_mcp_updates]"} {
2127 if strings.Contains(text, forbidden) {
2128 t.Fatalf("default config should omit %s:\n%s", forbidden, text)
2129 }
2130 }
2131 }
2132
2133 func captureStderr(t *testing.T, fn func()) string {
2134 t.Helper()
2135 old := os.Stderr
2136 r, w, err := os.Pipe()
2137 if err != nil {
2138 t.Fatal(err)
2139 }
2140 os.Stderr = w
2141 defer func() { os.Stderr = old }()
2142
2143 fn()
2144 if err := w.Close(); err != nil {
2145 t.Fatal(err)
2146 }
2147 data, err := io.ReadAll(r)
2148 if err != nil {
2149 t.Fatal(err)
2150 }
2151 return string(data)
2152 }
2153
2154 func captureCLIOutput(t *testing.T, fn func()) (stdout, stderr string) {
2155 t.Helper()
2156 stderr = captureStderr(t, func() {
2157 stdout = captureStdout(t, fn)
2158 })
2159 return stdout, stderr
2160 }
2161
2162 func TestProvidersWithMissingKeysOnlyReferenced(t *testing.T) {
2163 t.Setenv("DEEPSEEK_API_KEY", "")
2164 t.Setenv("MIMO_API_KEY", "")
2165 cfg := config.Default()
2166
2167 got := providersWithMissingKeys(cfg)
2168 envs := map[string]bool{}
2169 for _, p := range got {
2170 envs[p.APIKeyEnv] = true
2171 }
2172 if !envs["DEEPSEEK_API_KEY"] {
2173 t.Errorf("the default model's missing key must be prompted, got %v", got)
2174 }
2175 if envs["MIMO_API_KEY"] {
2176 t.Errorf("unreferenced preset keys must not be prompted, got %v", got)
2177 }
2178 }
2179
2180 func TestProvidersWithMissingKeysIncludesPlannerModel(t *testing.T) {
2181 t.Setenv("DEEPSEEK_API_KEY", "set")
2182 t.Setenv("MIMO_API_KEY", "")
2183 cfg := config.Default()
2184 cfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: "mimo-pro", Kind: "openai", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", APIKeyEnv: "MIMO_API_KEY"})
2185 cfg.Agent.PlannerModel = "mimo-pro"
2186
2187 got := providersWithMissingKeys(cfg)
2188 if len(got) != 1 || got[0].APIKeyEnv != "MIMO_API_KEY" {
2189 t.Errorf("planner model's missing key must be prompted, got %+v", got)
2190 }
2191 }
2192
2193 func TestParseRuntimeProfile(t *testing.T) {
2194 for input, want := range map[string]string{
2195 "": boot.TokenModeFull,
2196 "balanced": boot.TokenModeFull,
2197 "full": boot.TokenModeFull,
2198 "economy": boot.TokenModeEconomy,
2199 "delivery": boot.TokenModeDelivery,
2200 } {
2201 got, err := parseRuntimeProfile(input)
2202 if err != nil || got != want {
2203 t.Errorf("parseRuntimeProfile(%q) = %q, %v; want %q", input, got, err, want)
2204 }
2205 }
2206 if _, err := parseRuntimeProfile("fast"); err == nil {
2207 t.Fatal("unknown profile should fail")
2208 }
2209 }
2210
2210 lines GO