返回 DeepSeek-Reasonix
model_settings_test.go
根目录 / desktop / model_settings_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "strings"
14 "sync"
15 "sync/atomic"
16 "testing"
17 "time"
18
19 "reasonix/internal/boot"
20 "reasonix/internal/config"
21 "reasonix/internal/control"
22 "reasonix/internal/event"
23 "reasonix/internal/provider"
24 )
25
26 func modelSettingsBootTab(t *testing.T, app *App, id, root, model string) *WorkspaceTab {
27 t.Helper()
28 sessionDir := desktopSessionDir(root)
29 ctrl, err := boot.Build(app.ctx, boot.Options{Model: model, WorkspaceRoot: root, SessionDir: sessionDir, SessionService: app.desktopSessionService(sessionDir), Sink: event.Discard, BeforeInboxDispatch: app.beforeInboxDispatch})
30 if err != nil {
31 t.Fatal(err)
32 }
33 ref, err := ctrl.BindFreshSession(t.Context(), "model-settings-"+id)
34 if err != nil {
35 t.Fatal(err)
36 }
37 _, runtime, ok := ctrl.SessionBinding()
38 if !ok {
39 t.Fatal("controller did not publish v3 runtime")
40 }
41 appendSessionTestMessage(t, runtime, "model-settings-history-"+id, provider.Message{ID: "model-settings-user-" + id, Role: provider.RoleUser, Content: "keep history " + id})
42 tab := &WorkspaceTab{ID: id, Scope: "project", WorkspaceRoot: root, Ready: true, Ctrl: ctrl, model: model, SessionID: ref.SessionID, disabledMCP: map[string]ServerView{}, sink: &tabEventSink{tabID: id, app: app}}
43 if app.tabs == nil {
44 app.tabs = map[string]*WorkspaceTab{}
45 }
46 app.tabs[id] = tab
47 app.tabOrder = append(app.tabOrder, id)
48 installNoopRuntimeEvents(app, tab.sink)
49 t.Cleanup(func() {
50 if tab.Ctrl != nil {
51 tab.Ctrl.Close()
52 }
53 tab.releaseSessionLease()
54 })
55 return tab
56 }
57
58 func TestModelSettingsRemovalRetryKeepsFailedTargetAndAppliesInactiveSibling(t *testing.T) {
59 isolateDesktopUserDirs(t)
60 oldRef, newRef := configureSwitchableDefaultModels(t)
61 app := NewApp()
62 app.ctx = context.Background()
63 app.readyHook = func() {}
64 brokenRoot, workingRoot := t.TempDir(), t.TempDir()
65 broken := modelSettingsBootTab(t, app, "broken", brokenRoot, oldRef)
66 working := modelSettingsBootTab(t, app, "working", workingRoot, oldRef)
67 app.activeTabID = broken.ID
68 oldBroken, oldWorking := broken.Ctrl, working.Ctrl
69 if err := os.WriteFile(filepath.Join(brokenRoot, "reasonix.toml"), []byte("[agent]\nsystem_prompt_file = \"/outside-workspace/prompt.md\"\n"), 0600); err != nil {
70 t.Fatal(err)
71 }
72 if err := app.DeleteProvider("old"); err != nil {
73 t.Fatal(err)
74 }
75 result := app.RetryModelSettingsApplication(broken.ID)
76 if result.Application != "failed" || broken.Ctrl != oldBroken || broken.model != oldRef {
77 t.Fatalf("failed target was not preserved: %+v", result)
78 }
79 result = app.RetryModelSettingsApplication(working.ID)
80 if working.Ctrl == oldWorking || working.model != newRef || broken.Ctrl != oldBroken {
81 t.Fatal("retry did not target the inactive sibling")
82 }
83 for _, tab := range []*WorkspaceTab{broken, working} {
84 if got := tab.Ctrl.History(); len(got) < 2 || got[1].Content != "keep history "+tab.ID {
85 t.Fatalf("history lost for %s", tab.ID)
86 }
87 }
88 for _, target := range result.Targets {
89 if target.TabID == working.ID && target.Application != "applied" {
90 t.Fatalf("sibling status: %+v", target)
91 }
92 }
93 if admission, accepted, err := app.beginTabTurn(broken.ID, false); err == nil {
94 admission.abort()
95 applied, desired, stateErr := accepted.(modelSettingsSnapshot).ModelSettingsState()
96 t.Fatalf("failed target accepted: old=%v model=%q rootMatches=%v fresh=%v stateErr=%v status=%+v", accepted == oldBroken, broken.model, broken.WorkspaceRoot == brokenRoot, applied == desired, stateErr, app.GetModelSettingsApplication())
97 }
98 }
99
100 func TestModelSettingsProjectOverrideSkipsRebuild(t *testing.T) {
101 isolateDesktopUserDirs(t)
102 oldRef, newRef := configureSwitchableDefaultModels(t)
103 app := NewApp()
104 app.ctx = context.Background()
105 app.readyHook = func() {}
106 projectRoot := t.TempDir()
107 if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte("[agent]\nplanner_model = \""+oldRef+"\"\n"), 0600); err != nil {
108 t.Fatal(err)
109 }
110 project := modelSettingsBootTab(t, app, "project", projectRoot, oldRef)
111 global := modelSettingsBootTab(t, app, "global", t.TempDir(), oldRef)
112 app.activeTabID = project.ID
113 oldProject, oldGlobal := project.Ctrl, global.Ctrl
114 if err := app.SetPlannerModel(newRef); err != nil {
115 t.Fatal(err)
116 }
117 for _, tab := range []*WorkspaceTab{project, global} {
118 admission, _, err := app.beginTabTurn(tab.ID, false)
119 if err != nil {
120 t.Fatal(err)
121 }
122 admission.abort()
123 }
124 if project.Ctrl != oldProject || global.Ctrl == oldGlobal {
125 t.Fatal("effective project override was not honored at run admission")
126 }
127 }
128
129 func TestModelSettingsRetryAppliesDetachedRuntimeWithoutCreatingTab(t *testing.T) {
130 isolateDesktopUserDirs(t)
131 oldRef, newRef := configureSwitchableDefaultModels(t)
132 app := NewApp()
133 app.ctx = context.Background()
134 app.readyHook = func() {}
135 tab := modelSettingsBootTab(t, app, "background", t.TempDir(), oldRef)
136 if !app.detachSessionRuntime(tab) {
137 t.Fatal("detach runtime")
138 }
139 delete(app.tabs, tab.ID)
140 app.tabOrder = nil
141 app.activeTabID = ""
142 old := tab.Ctrl
143 if err := app.SetPlannerModel(newRef); err != nil {
144 t.Fatal(err)
145 }
146 result := app.RetryModelSettingsApplication(tab.ID)
147 if result.Application != "applied" || tab.Ctrl == old || len(app.tabs) != 0 || app.activeTabID != "" {
148 t.Fatalf("detached application created a tab or failed: %+v", result)
149 }
150 if history := tab.Ctrl.History(); len(history) < 2 || history[1].Content != "keep history background" {
151 t.Fatal("detached application lost history")
152 }
153 current := tab.Ctrl
154 if result := app.RetryModelSettingsApplication(tab.ID); result.Application != "applied" || tab.Ctrl != current {
155 t.Fatal("unchanged detached runtime rebuilt again")
156 }
157 }
158
159 func TestModelSettingsQueuedFollowupAppliesLatestBeforeDispatch(t *testing.T) {
160 isolateDesktopUserDirs(t)
161 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
162 defer cancel()
163 keys := make(chan string, 2)
164 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165 _, _ = io.Copy(io.Discard, r.Body)
166 keys <- r.Header.Get("Authorization")
167 w.Header().Set("Content-Type", "text/event-stream")
168 fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
169 }))
170 defer server.Close()
171 app := NewApp()
172 view := ProviderView{Name: "queued", Kind: "openai", BaseURL: server.URL, Models: []string{"m"}, NoProxy: true}
173 if _, err := app.SaveProviderWithKey(view, "old-queued-key"); err != nil {
174 t.Fatal(err)
175 }
176 app.ctx = ctx
177 app.readyHook = func() {}
178 tab := modelSettingsBootTab(t, app, "queued", t.TempDir(), "queued/m")
179 app.activeTabID = tab.ID
180 old := tab.Ctrl.(*control.Controller)
181 if err := old.SetInboxPaused(true); err != nil {
182 t.Fatal(err)
183 }
184 if _, err := old.TryEnqueueFollowup(control.InboxRequest{Display: "queued prompt", Submit: "queued prompt", Idempotency: "queued-settings-test"}); err != nil {
185 t.Fatal(err)
186 }
187 if _, err := app.SaveProviderWithKey(view, "new-queued-key"); err != nil {
188 t.Fatal(err)
189 }
190 done := make(chan struct{}, 1)
191 tab.sink.SetBotSink(event.FuncSink(func(e event.Event) {
192 if e.Kind == event.TurnDone {
193 done <- struct{}{}
194 }
195 }))
196 if err := old.SetInboxPaused(false); err != nil {
197 t.Fatal(err)
198 }
199 select {
200 case key := <-keys:
201 if key != "Bearer new-queued-key" {
202 t.Fatal("queued message used the retired connection")
203 }
204 case <-ctx.Done():
205 t.Fatal(ctx.Err())
206 }
207 select {
208 case <-done:
209 case <-ctx.Done():
210 t.Fatal(ctx.Err())
211 }
212 if app.controllerForTab(tab) == old {
213 t.Fatal("queued message did not replace the stale runtime")
214 }
215 }
216
217 func TestModelSettingsLastProviderRemovalBlocksNewRun(t *testing.T) {
218 isolateDesktopUserDirs(t)
219 oldRef, _ := configureSwitchableDefaultModels(t)
220 app := NewApp()
221 app.ctx = context.Background()
222 app.readyHook = func() {}
223 tab := modelSettingsBootTab(t, app, "last", t.TempDir(), oldRef)
224 app.activeTabID = tab.ID
225 old := tab.Ctrl
226 for _, name := range []string{"old", "new"} {
227 if err := app.DeleteProvider(name); err != nil {
228 t.Fatal(err)
229 }
230 }
231 if _, _, err := app.beginTabTurn(tab.ID, false); err == nil || !strings.Contains(err.Error(), "no configured model") {
232 t.Fatalf("no-model admission = %v", err)
233 }
234 if tab.Ctrl != old || len(tab.Ctrl.History()) < 2 {
235 t.Fatal("blocked admission destroyed current runtime/history")
236 }
237 }
238
239 func TestModelSettingsGroupedCredentialCommitAndReceipt(t *testing.T) {
240 isolateDesktopUserDirs(t)
241 setDesktopTestCredential(t, "GROUP_OLD_KEY", "old-key")
242 cfg := config.Default()
243 cfg.Providers = nil
244 for _, name := range []string{"first", "second", "unrelated"} {
245 cfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: name, Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "chat", APIKeyEnv: "GROUP_OLD_KEY"})
246 }
247 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
248 t.Fatal(err)
249 }
250 app := NewApp()
251 key := "new-key"
252 change := ModelSettingsChange{Kind: "credential", Names: []string{"first", "second"}, Key: &key, RequestID: "group-save", ExpectedFingerprint: app.Settings().ModelSettingsFingerprint}
253 result := app.ApplyModelSettings(change)
254 if !result.Persisted || result.Application != "not_required" || len(app.tabs) != 0 {
255 t.Fatalf("group save without session: %+v", result)
256 }
257 saved := config.LoadForEdit(config.UserConfigPath())
258 first, _ := saved.Provider("first")
259 second, _ := saved.Provider("second")
260 unrelated, _ := saved.Provider("unrelated")
261 if first.APIKeyEnv != second.APIKeyEnv || first.APIKeyEnv == unrelated.APIKeyEnv || !first.Configured() || unrelated.APIKeyEnv != "GROUP_OLD_KEY" {
262 t.Fatal("credential group did not switch together or changed unrelated connection")
263 }
264 before, _ := os.ReadFile(config.UserCredentialsPath())
265 replay := app.ApplyModelSettings(change)
266 receipt := app.GetModelSettingsRequest(change.RequestID)
267 after, _ := os.ReadFile(config.UserCredentialsPath())
268 if !replay.Persisted || !receipt.Persisted || replay.Revision != result.Revision || string(before) != string(after) {
269 t.Fatal("replayed save wrote another credential or lost its receipt")
270 }
271 key = ""
272 change.RequestID, change.ExpectedFingerprint = "group-clear", app.Settings().ModelSettingsFingerprint
273 result = app.ApplyModelSettings(change)
274 if !result.Persisted {
275 t.Fatalf("clear: %+v", result)
276 }
277 saved = config.LoadForEdit(config.UserConfigPath())
278 first, _ = saved.Provider("first")
279 second, _ = saved.Provider("second")
280 unrelated, _ = saved.Provider("unrelated")
281 if first.Configured() || second.Configured() || !unrelated.Configured() {
282 t.Fatal("clearing group did not preserve unrelated credential")
283 }
284 }
285
286 func TestModelSettingsFailedCommitCleansOnlyItsStagedCredential(t *testing.T) {
287 for _, failBeforeSave := range []bool{false, true} {
288 t.Run(fmt.Sprint(failBeforeSave), func(t *testing.T) {
289 isolateDesktopUserDirs(t)
290 configureSwitchableDefaultModels(t)
291 setDesktopTestCredential(t, "UNRELATED_KEY", "keep-me")
292 path := config.UserConfigPath()
293 before, err := os.ReadFile(path)
294 if err != nil {
295 t.Fatal(err)
296 }
297 var staged string
298 injected := errors.New("injected configuration commit failure")
299 _, err = NewApp().applyModelConfigChangeWithSave("test credential commit", func(c *config.Config) error {
300 var stageErr error
301 staged, stageErr = c.StageModelCredentialLocked("new-secret")
302 if stageErr != nil {
303 return stageErr
304 }
305 c.Providers[0].APIKeyEnv = staged
306 if failBeforeSave {
307 return injected
308 }
309 return nil
310 }, func(*config.Config, string) error { return injected })
311 if !errors.Is(err, injected) {
312 t.Fatalf("failure = %v", err)
313 }
314 after, readErr := os.ReadFile(path)
315 if readErr != nil || string(before) != string(after) {
316 t.Fatal("failed edit modified committed config")
317 }
318 if staged == "" || config.CredentialStored(staged) || !config.CredentialStored("UNRELATED_KEY") {
319 t.Fatal("failed edit did not clean only its new credential")
320 }
321 })
322 }
323 }
324
325 func TestModelSettingsCredentialWriteFailureKeepsConfig(t *testing.T) {
326 isolateDesktopUserDirs(t)
327 configureSwitchableDefaultModels(t)
328 before, _ := os.ReadFile(config.UserConfigPath())
329 if err := os.Remove(config.UserCredentialsPath()); err != nil {
330 t.Fatal(err)
331 }
332 if err := os.MkdirAll(config.UserCredentialsPath(), 0700); err != nil {
333 t.Fatal(err)
334 }
335 app := NewApp()
336 key := "secret"
337 cfg := config.LoadForEdit(config.UserConfigPath())
338 result := app.ApplyModelSettings(ModelSettingsChange{Kind: "credential", Name: cfg.Providers[0].Name, Key: &key, RequestID: "credential-failure", ExpectedFingerprint: app.Settings().ModelSettingsFingerprint})
339 after, _ := os.ReadFile(config.UserConfigPath())
340 if result.Persisted || len(result.Issues) == 0 || string(before) != string(after) {
341 t.Fatalf("credential write failure: %+v", result)
342 }
343 }
344
345 func TestModelSettingsSaveWithoutActiveSession(t *testing.T) {
346 isolateDesktopUserDirs(t)
347 _, ref := configureSwitchableDefaultModels(t)
348 app := NewApp()
349 app.ctx = context.Background()
350 for _, tc := range []struct {
351 name string
352 run func() error
353 }{
354 {"default", func() error { return app.SetDefaultModel(ref) }},
355 {"planner", func() error { return app.SetPlannerModel(ref) }},
356 {"vision", func() error { return app.SetVisionModel("") }},
357 {"search", func() error { return app.SetWebSearchModel("auto") }},
358 {"subagent", func() error { return app.SetSubagentModel(ref) }},
359 {"effort", func() error { return app.SetSubagentEffort("auto") }},
360 } {
361 t.Run(tc.name, func(t *testing.T) {
362 if err := tc.run(); err != nil {
363 t.Fatal(err)
364 }
365 })
366 }
367 if len(app.tabs) != 0 || app.activeTabID != "" {
368 t.Fatal("saving created a session")
369 }
370 if config.LoadForEdit(config.UserConfigPath()).DefaultModel != ref {
371 t.Fatal("default not persisted")
372 }
373 }
374
375 func TestModelSettingsRequestRejectsStaleEdit(t *testing.T) {
376 isolateDesktopUserDirs(t)
377 oldRef, ref := configureSwitchableDefaultModels(t)
378 app := NewApp()
379 if err := app.SetDefaultModel(oldRef); err != nil {
380 t.Fatal(err)
381 }
382 fingerprint := app.Settings().ModelSettingsFingerprint
383 // Wails crosses JSON in both directions. Binary HMAC strings would be
384 // replaced with U+FFFD, causing every real browser submission to conflict.
385 wire, err := json.Marshal(fingerprint)
386 if err != nil {
387 t.Fatal(err)
388 }
389 var roundTrip string
390 if err := json.Unmarshal(wire, &roundTrip); err != nil || roundTrip != fingerprint {
391 t.Fatalf("fingerprint is not JSON stable: %v", err)
392 }
393 first := app.ApplyModelSettings(ModelSettingsChange{Kind: "preference", Field: "default", Ref: ref, RequestID: "first", ExpectedFingerprint: fingerprint})
394 if !first.Persisted {
395 t.Fatalf("save: %+v", first.Issues)
396 }
397 second := app.ApplyModelSettings(ModelSettingsChange{Kind: "preference", Field: "default", Ref: oldRef, RequestID: "second", ExpectedFingerprint: fingerprint})
398 if second.Persisted || len(second.Issues) == 0 {
399 t.Fatal("stale edit accepted")
400 }
401 if config.LoadForEdit(config.UserConfigPath()).DefaultModel != ref {
402 t.Fatal("stale edit overwrote committed config")
403 }
404 encoded, err := json.Marshal(first)
405 if err != nil || strings.Contains(string(encoded), ":null") {
406 t.Fatalf("array contract: %s %v", encoded, err)
407 }
408 }
409
410 func TestModelSettingsRequestReceiptSurvivesRestart(t *testing.T) {
411 isolateDesktopUserDirs(t)
412 oldRef, newRef := configureSwitchableDefaultModels(t)
413 firstApp := NewApp()
414 change := ModelSettingsChange{
415 Kind: "preference", Field: "default", Ref: newRef, RequestID: "restart-receipt",
416 ExpectedFingerprint: firstApp.Settings().ModelSettingsFingerprint,
417 }
418 first := firstApp.ApplyModelSettings(change)
419 if !first.Persisted {
420 t.Fatalf("initial save: %+v", first)
421 }
422
423 restarted := NewApp()
424 receipt := restarted.GetModelSettingsRequest(change.RequestID)
425 if !receipt.Persisted || receipt.Revision != first.Revision {
426 t.Fatalf("restart receipt = %+v, want persisted revision %q", receipt, first.Revision)
427 }
428 before, err := os.ReadFile(config.UserConfigPath())
429 if err != nil {
430 t.Fatal(err)
431 }
432 replayed := restarted.ApplyModelSettings(change)
433 after, err := os.ReadFile(config.UserConfigPath())
434 if err != nil {
435 t.Fatal(err)
436 }
437 if !replayed.Persisted || string(before) != string(after) {
438 t.Fatal("restart replay performed another write")
439 }
440
441 change.Ref = oldRef
442 conflict := restarted.ApplyModelSettings(change)
443 if conflict.Persisted || len(conflict.Issues) == 0 || conflict.Issues[0].Code != "request_conflict" {
444 t.Fatalf("request ID conflict = %+v", conflict)
445 }
446 }
447
448 func TestModelSettingsRunningToolContinuationKeepsOldConnection(t *testing.T) {
449 isolateDesktopUserDirs(t)
450 root := t.TempDir()
451 t.Chdir(root)
452 if err := os.WriteFile(filepath.Join(root, "input.txt"), []byte("test input"), 0600); err != nil {
453 t.Fatal(err)
454 }
455 started, release := make(chan struct{}), make(chan struct{})
456 var once sync.Once
457 t.Cleanup(func() { once.Do(func() { close(release) }) })
458 var calls atomic.Int32
459 auth := make(chan string, 8)
460 oldServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
461 _, _ = io.Copy(io.Discard, r.Body)
462 auth <- r.Header.Get("Authorization")
463 w.Header().Set("Content-Type", "text/event-stream")
464 if calls.Add(1) == 1 {
465 close(started)
466 select {
467 case <-release:
468 case <-r.Context().Done():
469 return
470 }
471 fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"read-1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"input.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n")
472 return
473 }
474 fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
475 }))
476 t.Cleanup(oldServer.Close)
477 newAuth := make(chan string, 8)
478 newServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
479 _, _ = io.Copy(io.Discard, r.Body)
480 newAuth <- r.Header.Get("Authorization")
481 w.Header().Set("Content-Type", "text/event-stream")
482 fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"new\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
483 }))
484 t.Cleanup(newServer.Close)
485 app := NewApp()
486 view := ProviderView{Name: "snapshot-test", Kind: "openai", BaseURL: oldServer.URL, Models: []string{"m"}, NoProxy: true}
487 if _, err := app.SaveProviderWithKey(view, "old-credential"); err != nil {
488 t.Fatal(err)
489 }
490 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
491 t.Cleanup(cancel)
492 old, err := boot.Build(ctx, boot.Options{Model: "snapshot-test/m", WorkspaceRoot: root, Sink: event.Discard})
493 if err != nil {
494 t.Fatal(err)
495 }
496 app.ctx = ctx
497 app.readyHook = func() {}
498 app.setTestCtrl(old, "snapshot-test/m")
499 tab := app.activeTab()
500 tab.WorkspaceRoot = root
501 path := filepath.Join(old.SessionDir(), "snapshot-test.jsonl")
502 old.AdoptHistory(old.History(), path)
503 tab.SessionPath = path
504 installNoopRuntimeEvents(app, tab.sink)
505 t.Cleanup(func() {
506 once.Do(func() { close(release) })
507 cancel()
508 if tab.Ctrl != nil {
509 tab.Ctrl.Close()
510 }
511 tab.releaseSessionLease()
512 })
513 if needed, err := modelSettingsNeedApply(old); err != nil || needed {
514 t.Fatalf("fresh boot stale: %v %v", needed, err)
515 }
516 done := make(chan error, 1)
517 go func() { done <- old.RunTurn(ctx, "Read input.txt") }()
518 select {
519 case <-started:
520 case <-ctx.Done():
521 t.Fatal(ctx.Err())
522 }
523 view.BaseURL = newServer.URL
524 if _, err := app.SaveProviderWithKey(view, "new-credential"); err != nil {
525 t.Fatal(err)
526 }
527 if app.activeCtrl() != old || !old.RuntimeStatus().Running {
528 t.Fatal("save replaced or stopped accepted work")
529 }
530 if status := app.GetModelSettingsApplication(); status.Application != "pending" {
531 t.Fatalf("status: %+v", status)
532 }
533 once.Do(func() { close(release) })
534 if err := <-done; err != nil {
535 t.Fatal(err)
536 }
537 if calls.Load() != 2 {
538 t.Fatalf("expected tool continuation, got %d requests", calls.Load())
539 }
540 for range 2 {
541 if got := <-auth; got != "Bearer old-credential" {
542 t.Fatalf("current work switched key: %q", got)
543 }
544 }
545 admission, current, err := app.beginTabTurn(tab.ID, false)
546 if err != nil {
547 t.Fatal(err)
548 }
549 admission.abort()
550 if current == old {
551 t.Fatal("next run retained stale controller")
552 }
553 admission, same, err := app.beginTabTurn(tab.ID, false)
554 if err != nil {
555 t.Fatal(err)
556 }
557 admission.abort()
558 if same != current {
559 t.Fatal("unchanged config rebuilt a second time")
560 }
561 if err := current.(*control.Controller).RunTurn(ctx, "Next run"); err != nil {
562 t.Fatal(err)
563 }
564 if got := <-newAuth; got != "Bearer new-credential" {
565 t.Fatalf("next run key: %q", got)
566 }
567 }
568
569 func TestModelSettingsSaveDoesNotTouchRunningController(t *testing.T) {
570 isolateDesktopUserDirs(t)
571 _, ref := configureSwitchableDefaultModels(t)
572 app := NewApp()
573 app.ctx = context.Background()
574 old := control.New(control.Options{Sink: event.Discard})
575 t.Cleanup(old.Close)
576 tab := &WorkspaceTab{ID: "running", Ctrl: old, Ready: true}
577 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
578 app.activeTabID = tab.ID
579 if err := app.SetPlannerModel(ref); err != nil {
580 t.Fatal(err)
581 }
582 if app.controllerForTab(tab) != old {
583 t.Fatal("saving replaced the current controller")
584 }
585 }
586
587 func TestModelSettingsValidationDoesNotWriteCredential(t *testing.T) {
588 isolateDesktopUserDirs(t)
589 _, _ = configureSwitchableDefaultModels(t)
590 before, _ := os.ReadFile(config.UserCredentialsPath())
591 app := NewApp()
592 if _, err := app.SaveProviderWithKey(ProviderView{Name: "", Kind: "invalid"}, "test-not-a-real-key"); err == nil {
593 t.Fatal("invalid provider accepted")
594 }
595 after, _ := os.ReadFile(config.UserCredentialsPath())
596 if string(before) != string(after) {
597 t.Fatal("validation failure wrote credentials")
598 }
599 }
600
601 func TestModelSettingsApplyFailureKeepsSavedConfiguration(t *testing.T) {
602 isolateDesktopUserDirs(t)
603 _, ref := configureSwitchableDefaultModels(t)
604 app := NewApp()
605 fingerprint := app.Settings().ModelSettingsFingerprint
606 r := app.ApplyModelSettings(ModelSettingsChange{Kind: "preference", Field: "planner", Ref: ref, RequestID: "save", ExpectedFingerprint: fingerprint})
607 if !r.Persisted {
608 t.Fatalf("save: %+v", r.Issues)
609 }
610 retry := app.RetryModelSettingsApplication("closed-session")
611 if !retry.Persisted || retry.Application != "failed" {
612 t.Fatalf("retry: %+v", retry)
613 }
614 if config.LoadForEdit(config.UserConfigPath()).Agent.PlannerModel != ref {
615 t.Fatal("retry altered saved configuration")
616 }
617 }
618
618 lines GO