| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/desktop/internal/draftstate" |
| 12 | "reasonix/internal/config" |
| 13 | ) |
| 14 | |
| 15 | func writeDraftDefaultModelConfig(t *testing.T, model string) { |
| 16 | t.Helper() |
| 17 | if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | payload := fmt.Sprintf(`default_model = "fixture/%s" |
| 21 | |
| 22 | [desktop] |
| 23 | provider_access = ["fixture"] |
| 24 | |
| 25 | [[providers]] |
| 26 | name = "fixture" |
| 27 | kind = "openai" |
| 28 | base_url = "https://example.invalid/v1" |
| 29 | models = ["model-a", "model-b"] |
| 30 | default = "model-a" |
| 31 | api_key_env = "DRAFT_DEFAULT_MODEL_KEY" |
| 32 | `, model) |
| 33 | if err := os.WriteFile(config.UserConfigPath(), []byte(payload), 0o600); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | if _, err := config.SetCredential("DRAFT_DEFAULT_MODEL_KEY", "test-key"); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func TestInheritedDraftModelFollowsDefaultUntilExplicitlySelected(t *testing.T) { |
| 42 | isolateDesktopUserDirs(t) |
| 43 | writeDraftDefaultModelConfig(t, "model-a") |
| 44 | a := newDraftTestApp(t) |
| 45 | root := t.TempDir() |
| 46 | |
| 47 | draft, err := a.OpenSessionDraftForTarget("project", root) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | if draft.Settings.Model != "fixture/model-a" || draft.Settings.ModelSource != draftModelSourceDefault { |
| 52 | t.Fatalf("initial model = %+v, want inherited fixture/model-a", draft.Settings) |
| 53 | } |
| 54 | |
| 55 | writeDraftDefaultModelConfig(t, "model-b") |
| 56 | reopened, err := a.OpenSessionDraftForTarget("project", root) |
| 57 | if err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | if reopened.ID != draft.ID || reopened.Settings.Model != "fixture/model-b" || reopened.Settings.ModelSource != draftModelSourceDefault { |
| 61 | t.Fatalf("reopened draft = %+v, want same draft following fixture/model-b", reopened) |
| 62 | } |
| 63 | |
| 64 | explicit := reopened.Settings |
| 65 | explicit.Model = "fixture/model-a" |
| 66 | explicit.ModelSource = draftModelSourceExplicit |
| 67 | saved, err := a.SaveSessionDraft(SessionDraftSaveRequest{ |
| 68 | DraftID: reopened.ID, Revision: reopened.Revision, ContentJSON: reopened.ContentJSON, Settings: explicit, |
| 69 | }) |
| 70 | if err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | final, err := a.OpenSessionDraftForTarget("project", root) |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if final.Revision != saved.Draft.Revision || final.Settings.Model != "fixture/model-a" || final.Settings.ModelSource != draftModelSourceExplicit { |
| 78 | t.Fatalf("explicit model was not retained: %+v", final.Settings) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestUntouchedLegacyDraftMigratesToLiveDefaultButEditedLegacyDraftDoesNot(t *testing.T) { |
| 83 | isolateDesktopUserDirs(t) |
| 84 | writeDraftDefaultModelConfig(t, "model-a") |
| 85 | a := newDraftTestApp(t) |
| 86 | |
| 87 | openLegacy := func(root, workspaceID, draftID string) draftstate.Draft { |
| 88 | t.Helper() |
| 89 | settings := a.defaultDraftSettings("project", root) |
| 90 | settings.ModelSource = "" |
| 91 | payload, err := json.Marshal(settings) |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | record, _, err := a.draftStore().Open(t.Context(), workspaceID, "project", root, draftID, string(payload)) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | return record |
| 100 | } |
| 101 | |
| 102 | untouchedRoot := t.TempDir() |
| 103 | untouchedWorkspace, err := a.ensureDesktopWorkspace(t.Context(), "project", untouchedRoot) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | untouched := openLegacy(untouchedRoot, untouchedWorkspace, "legacy-untouched") |
| 108 | |
| 109 | editedRoot := t.TempDir() |
| 110 | editedWorkspace, err := a.ensureDesktopWorkspace(t.Context(), "project", editedRoot) |
| 111 | if err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | edited := openLegacy(editedRoot, editedWorkspace, "legacy-edited") |
| 115 | edited, err = a.draftStore().Save(t.Context(), edited.ID, edited.Revision, `{"text":"saved"}`, edited.SettingsJSON, false) |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | |
| 120 | writeDraftDefaultModelConfig(t, "model-b") |
| 121 | migrated, err := a.OpenSessionDraftForTarget("project", untouchedRoot) |
| 122 | if err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | if migrated.ID != untouched.ID || migrated.Settings.Model != "fixture/model-b" || migrated.Settings.ModelSource != draftModelSourceDefault { |
| 126 | t.Fatalf("untouched legacy draft = %+v, want migrated live default", migrated) |
| 127 | } |
| 128 | preserved, err := a.OpenSessionDraftForTarget("project", editedRoot) |
| 129 | if err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | if preserved.ID != edited.ID || preserved.Settings.Model != "fixture/model-a" || preserved.Settings.ModelSource != "" { |
| 133 | t.Fatalf("edited legacy draft = %+v, want preserved concrete model", preserved) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestLegacyDraftWithOperationKeepsFrozenModelAndStillOpens(t *testing.T) { |
| 138 | isolateDesktopUserDirs(t) |
| 139 | writeDraftDefaultModelConfig(t, "model-a") |
| 140 | a := newDraftTestApp(t) |
| 141 | root := t.TempDir() |
| 142 | workspaceID, err := a.ensureDesktopWorkspace(t.Context(), "project", root) |
| 143 | if err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | settings := a.defaultDraftSettings("project", root) |
| 147 | settings.ModelSource = "" |
| 148 | payload, err := json.Marshal(settings) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | draft, _, err := a.draftStore().Open(t.Context(), workspaceID, "project", root, "legacy-operation", string(payload)) |
| 153 | if err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | request, err := json.Marshal(SessionDraftSubmissionRequest{ |
| 157 | SnapshotVersion: 3, |
| 158 | Settings: settings, |
| 159 | }) |
| 160 | if err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | if _, _, err := a.draftStore().BeginOperation(t.Context(), draftstate.Operation{ |
| 164 | ID: "legacy-operation-op", DraftID: draft.ID, WorkspaceID: workspaceID, DraftRevision: draft.Revision, |
| 165 | SessionID: "legacy-operation-session", TopicID: "legacy-operation-topic", |
| 166 | SubmissionID: "legacy-operation-submission", Fingerprint: "legacy-operation-fingerprint", RequestJSON: string(request), |
| 167 | }); err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | |
| 171 | writeDraftDefaultModelConfig(t, "model-b") |
| 172 | reopened, err := a.OpenSessionDraftForTarget("project", root) |
| 173 | if err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | if reopened.Revision != draft.Revision || reopened.Settings.Model != "fixture/model-a" || reopened.Settings.ModelSource != "" { |
| 177 | t.Fatalf("operation-owned legacy draft changed during migration: %+v", reopened) |
| 178 | } |
| 179 | op, err := a.draftStore().Operation(t.Context(), "legacy-operation-op") |
| 180 | if err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | frozen, err := a.draftOperationSettings(op) |
| 184 | if err != nil || frozen.Model != "fixture/model-a" { |
| 185 | t.Fatalf("frozen operation model = %+v, err %v", frozen, err) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | func TestDraftSubmissionFingerprintIgnoresInheritedDefaultMirror(t *testing.T) { |
| 190 | base := SessionDraftSubmissionRequest{DraftID: "draft", Revision: 1, Display: "hi", Input: "hi", |
| 191 | Settings: SessionDraftSettings{Model: "fixture/model-a", ModelSource: draftModelSourceDefault}} |
| 192 | first, _, err := draftSubmissionFingerprint(base) |
| 193 | if err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | base.Settings.Model = "fixture/model-b" |
| 197 | second, _, err := draftSubmissionFingerprint(base) |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | if first != second { |
| 202 | t.Fatalf("inherited mirror changed request identity: %s != %s", first, second) |
| 203 | } |
| 204 | base.Settings.ModelSource = draftModelSourceExplicit |
| 205 | explicit, _, err := draftSubmissionFingerprint(base) |
| 206 | if err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | if explicit == second { |
| 210 | t.Fatal("explicit model did not participate in request identity") |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestDraftAdmissionPreservesModelAliasRequestIdentity(t *testing.T) { |
| 215 | isolateDesktopUserDirs(t) |
| 216 | writeDraftDefaultModelConfig(t, "model-a") |
| 217 | a := newDraftTestApp(t) |
| 218 | draft, err := a.OpenSessionDraftForTarget("project", t.TempDir()) |
| 219 | if err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | settings := draft.Settings |
| 223 | settings.Model, settings.ModelSource = "model-a", draftModelSourceExplicit |
| 224 | saved, err := a.SaveSessionDraft(SessionDraftSaveRequest{ |
| 225 | DraftID: draft.ID, Revision: draft.Revision, ContentJSON: `{"text":"hello"}`, Settings: settings, |
| 226 | }) |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | request := SessionDraftSubmissionRequest{ |
| 231 | SnapshotVersion: draftstate.SnapshotVersion, DraftID: draft.ID, Revision: saved.Draft.Revision, |
| 232 | SourceDigest: saved.Draft.SnapshotDigest, Display: "hello", Input: "hello", Settings: settings, |
| 233 | } |
| 234 | fingerprint, _, err := draftSubmissionFingerprint(request) |
| 235 | if err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | // Seed an already admitted operation to exercise admission without starting a |
| 239 | // provider. Its execution snapshot is canonical, but identity belongs to the |
| 240 | // original wire request, including its explicit /model alias. |
| 241 | frozen := request |
| 242 | frozen.Settings.Model = "fixture/model-a" |
| 243 | _, payload, err := draftSubmissionFingerprint(frozen) |
| 244 | if err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | op, _, err := a.draftStore().BeginOperation(t.Context(), draftstate.Operation{ |
| 248 | ID: "alias-operation", RequestID: "original-request", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, |
| 249 | DraftRevision: saved.Draft.Revision, SourceDigest: saved.Draft.SnapshotDigest, |
| 250 | SessionID: "alias-session", TopicID: "alias-topic", SubmissionID: "alias-submission", |
| 251 | Fingerprint: fingerprint, RequestJSON: payload, |
| 252 | }) |
| 253 | if err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | if _, err := a.draftStore().SetOperationPhase(t.Context(), op.ID, "accepted", ""); err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | for _, requestID := range []string{"original-request", "second-equivalent-request"} { |
| 260 | request.RequestID = requestID |
| 261 | got, err := a.BeginDraftSubmission(request) |
| 262 | if err != nil || got.OperationID != op.ID { |
| 263 | t.Fatalf("equivalent request %s: %+v, %v", requestID, got, err) |
| 264 | } |
| 265 | } |
| 266 | request.RequestID, request.Input = "original-request", "different input" |
| 267 | if _, err := a.BeginDraftSubmission(request); !errors.Is(err, draftstate.ErrOperationConflict) { |
| 268 | t.Fatalf("changed retry payload = %v, want operation conflict", err) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | func TestDraftRecoveryProjectsFrozenModelUntilEditingResumes(t *testing.T) { |
| 273 | isolateDesktopUserDirs(t) |
| 274 | writeDraftDefaultModelConfig(t, "model-a") |
| 275 | a := newDraftTestApp(t) |
| 276 | draft, err := a.OpenSessionDraftForTarget("project", t.TempDir()) |
| 277 | if err != nil { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | request := SessionDraftSubmissionRequest{SnapshotVersion: draftstate.SnapshotVersion, Settings: draft.Settings} |
| 281 | _, payload, err := draftSubmissionFingerprint(request) |
| 282 | if err != nil { |
| 283 | t.Fatal(err) |
| 284 | } |
| 285 | op, _, err := a.draftStore().BeginOperation(t.Context(), draftstate.Operation{ |
| 286 | ID: "frozen-view-operation", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision, |
| 287 | SessionID: "frozen-view-session", TopicID: "frozen-view-topic", SubmissionID: "frozen-view-submission", |
| 288 | Fingerprint: "frozen-view", RequestJSON: payload, |
| 289 | }) |
| 290 | if err != nil { |
| 291 | t.Fatal(err) |
| 292 | } |
| 293 | writeDraftDefaultModelConfig(t, "model-b") |
| 294 | for _, phase := range []string{"reserved", "resume_required", "runtime_failed", "terminal_failed", "cancelled"} { |
| 295 | if _, err := a.draftStore().SetOperationPhase(t.Context(), op.ID, phase, ""); err != nil { |
| 296 | t.Fatal(err) |
| 297 | } |
| 298 | want := "fixture/model-a" |
| 299 | if phase == "terminal_failed" || phase == "cancelled" { |
| 300 | want = "fixture/model-b" |
| 301 | } |
| 302 | state, err := a.GetSessionDraftState(draft.ID) |
| 303 | if err != nil || state.Draft.Settings.Model != want { |
| 304 | t.Fatalf("%s state model = %q, %v; want %s", phase, state.Draft.Settings.Model, err, want) |
| 305 | } |
| 306 | current := "" |
| 307 | for _, model := range a.ModelsForDraft(draft.ID) { |
| 308 | if model.Current { |
| 309 | current = model.Ref |
| 310 | } |
| 311 | } |
| 312 | if current != want { |
| 313 | t.Fatalf("%s picker model = %q; want %s (catalog: %+v)", phase, current, want, a.ModelsForDraft(draft.ID)) |
| 314 | } |
| 315 | } |
| 316 | } |
| 317 |