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