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