| 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 |