返回 DeepSeek-Reasonix
session_draft_test.go
根目录 / desktop / session_draft_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14
15 "reasonix/desktop/internal/draftstate"
16 "reasonix/desktop/internal/workspacestate"
17 "reasonix/internal/boot"
18 "reasonix/internal/config"
19 "reasonix/internal/control"
20 )
21
22 func TestDraftAdmissionErrorPreservesWrappedCodedError(t *testing.T) {
23 coded := &inboxCodedError{code: "image_attachment_unreadable", cause: errors.New("missing image")}
24 wrapped := fmt.Errorf("validate draft: %w", coded)
25
26 got := draftAdmissionError(wrapped)
27 var found *inboxCodedError
28 if !errors.As(got, &found) || found != coded {
29 t.Fatalf("draftAdmissionError() = %v, want wrapped coded error", got)
30 }
31 if strings.Contains(got.Error(), "draft submission not admitted") {
32 t.Fatalf("draftAdmissionError() added generic prefix: %v", got)
33 }
34 }
35
36 func beginDraftTestOperation(t *testing.T, a *App, phase string) (draftstate.Draft, draftstate.Operation) {
37 t.Helper()
38 draft, _, err := a.draftStore().Open(context.Background(), "workspace", "project", t.TempDir(), "draft-"+phase, `{}`)
39 if err != nil {
40 t.Fatal(err)
41 }
42 op, _, err := a.draftStore().BeginOperation(context.Background(), draftstate.Operation{
43 ID: "operation-" + phase, DraftID: draft.ID, WorkspaceID: draft.WorkspaceID,
44 DraftRevision: draft.Revision, SessionID: "session-" + phase, TopicID: "topic-" + phase,
45 SubmissionID: "submission-" + phase, Fingerprint: "fingerprint-" + phase, RequestJSON: `{}`,
46 })
47 if err != nil {
48 t.Fatal(err)
49 }
50 if phase != "reserved" {
51 op, err = a.draftStore().SetOperationPhase(context.Background(), op.ID, phase, "")
52 if err != nil {
53 t.Fatal(err)
54 }
55 }
56 return draft, op
57 }
58
59 func newDraftTestApp(t *testing.T) *App {
60 t.Helper()
61 a := NewApp()
62 a.desktopDrafts = draftstate.New(filepath.Join(t.TempDir(), "drafts.sqlite"))
63 t.Cleanup(func() { _ = a.desktopDrafts.Close() })
64 return a
65 }
66
67 func TestDraftExternalFolderWithSpacesUsesStableRuntimeToken(t *testing.T) {
68 path := filepath.Join(string(filepath.Separator), "Users", "example", "Folder With Spaces")
69 input := "inspect @" + path + "/ and keep this visible"
70 got := rewriteDraftExternalFolderRef(input, path, "__reasonix_external_folder/abc/Folder-With-Spaces")
71 want := "inspect @__reasonix_external_folder/abc/Folder-With-Spaces/ and keep this visible"
72 if got != want {
73 t.Fatalf("rewritten input = %q, want %q", got, want)
74 }
75 }
76
77 func TestDraftHostIdentitiesDoNotEnterProviderSubmission(t *testing.T) {
78 request := SessionDraftSubmissionRequest{
79 DraftID: "draft-secret", Display: "display", Input: "input", Goal: "goal",
80 ToolApprovalMode: "ask", Invocations: []InvocationRequest{{Name: "skill", Kind: "skill"}},
81 }
82 providerRequest := draftControlSubmissionRequest("submission-secret", request)
83 body, err := json.Marshal(providerRequest)
84 if err != nil {
85 t.Fatal(err)
86 }
87 if string(body) == "" || containsAny(string(body), "draft-secret", "submission-secret") {
88 t.Fatalf("host identity leaked into provider request: %s", body)
89 }
90 }
91
92 func TestDraftModelResolutionIsStrict(t *testing.T) {
93 cfg := &config.Config{Providers: []config.ProviderEntry{{Name: "fixture", Model: "model-a"}}}
94 if _, err := resolveDraftCreateModelStrict(cfg, "removed/model"); !errors.Is(err, boot.ErrUnknownModel) {
95 t.Fatalf("strict resolution error = %v, want boot.ErrUnknownModel", err)
96 }
97 resolved, err := resolveDraftCreateModelStrict(cfg, "model-a")
98 if err != nil || resolved != "fixture/model-a" {
99 t.Fatalf("alias resolution = %q, %v", resolved, err)
100 }
101 pluginRef := "plugin/example/model-a"
102 resolved, err = resolveDraftCreateModelStrict(cfg, pluginRef)
103 if err != nil || resolved != pluginRef {
104 t.Fatalf("plugin resolution = %q, %v", resolved, err)
105 }
106 }
107
108 func TestModelsForDraftUsesItsWorkspaceConfiguration(t *testing.T) {
109 isolateDesktopUserDirs(t)
110 if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
111 t.Fatal(err)
112 }
113 if err := os.WriteFile(config.UserConfigPath(), []byte(`
114 default_model = "local/model-a"
115
116 [desktop]
117 provider_access = ["local"]
118
119 [[providers]]
120 name = "local"
121 kind = "openai"
122 base_url = "http://127.0.0.1:23333/v1"
123 models = ["model-a", "model-b"]
124 default = "model-a"
125 `), 0o644); err != nil {
126 t.Fatal(err)
127 }
128 a := newDraftTestApp(t)
129 rootA, rootB := t.TempDir(), t.TempDir()
130 if err := os.WriteFile(filepath.Join(rootA, "reasonix.toml"), []byte(`default_model = "local/model-a"`), 0o644); err != nil {
131 t.Fatal(err)
132 }
133 if err := os.WriteFile(filepath.Join(rootB, "reasonix.toml"), []byte(`default_model = "local/model-b"`), 0o644); err != nil {
134 t.Fatal(err)
135 }
136 draftA, err := a.OpenSessionDraftForTarget("project", rootA)
137 if err != nil {
138 t.Fatal(err)
139 }
140 draftB, err := a.OpenSessionDraftForTarget("project", rootB)
141 if err != nil {
142 t.Fatal(err)
143 }
144 current := func(models []ModelInfo) string {
145 for _, model := range models {
146 if model.Current {
147 return model.Ref
148 }
149 }
150 return ""
151 }
152 if got := current(a.ModelsForDraft(draftA.ID)); got != "local/model-a" {
153 t.Fatalf("workspace A current model = %q, want local/model-a", got)
154 }
155 if got := current(a.ModelsForDraft(draftB.ID)); got != "local/model-b" {
156 t.Fatalf("workspace B current model = %q, want local/model-b", got)
157 }
158 }
159
160 func TestDraftRetryAppliesFrozenSettingsToReservedSession(t *testing.T) {
161 a := newDraftTestApp(t)
162 oldEffort := "low"
163 tab := &WorkspaceTab{
164 ID: "tab", SessionID: "session", PendingCreateOperationID: "old-operation",
165 model: "old/model", effort: &oldEffort, mode: "normal", toolApprovalMode: "ask",
166 disabledMCP: map[string]ServerView{}, mcpOrder: []string{"old"},
167 }
168 a.tabs[tab.ID] = tab
169 a.tabOrder = []string{tab.ID}
170 settings := SessionDraftSettings{
171 Model: "new/model", Effort: "high", QualityFloor: "high", CollaborationMode: "plan",
172 ToolApprovalMode: control.ToolApprovalDangerFullAccess,
173 DisabledMCP: map[string]ServerView{"disabled": {Name: "disabled"}}, MCPOrder: []string{"disabled"},
174 }
175 if err := a.applyDraftOperationSettings(draftstate.Operation{ID: "new-operation", SessionID: tab.SessionID}, settings); err != nil {
176 t.Fatal(err)
177 }
178 if tab.PendingCreateOperationID != "new-operation" || tab.model != "new/model" || tab.effort == nil || *tab.effort != "high" {
179 t.Fatalf("retry identity/model/effort = %q / %q / %+v", tab.PendingCreateOperationID, tab.model, tab.effort)
180 }
181 if tab.qualityFloor != "high" || !tabModeHasPlan(tab.mode) || normalizeToolApprovalMode(tab.toolApprovalMode) != control.ToolApprovalDangerFullAccess {
182 t.Fatalf("retry profile = quality %q mode %q approval %q", tab.qualityFloor, tab.mode, tab.toolApprovalMode)
183 }
184 if _, ok := tab.disabledMCP["disabled"]; !ok || len(tab.mcpOrder) != 1 || tab.mcpOrder[0] != "disabled" {
185 t.Fatalf("retry MCP profile = disabled %+v order %+v", tab.disabledMCP, tab.mcpOrder)
186 }
187 }
188
189 func containsAny(value string, needles ...string) bool {
190 for _, needle := range needles {
191 if strings.Contains(value, needle) {
192 return true
193 }
194 }
195 return false
196 }
197
198 func TestOpenSessionDraftDoesNotCreateRuntimeArtifacts(t *testing.T) {
199 a := newDraftTestApp(t)
200 root := t.TempDir()
201 var id string
202 for range 20 {
203 draft, err := a.OpenSessionDraftForTarget("project", root)
204 if err != nil {
205 t.Fatalf("OpenSessionDraftForTarget() error = %v", err)
206 }
207 if id == "" {
208 id = draft.ID
209 }
210 if draft.ID != id {
211 t.Fatalf("draft ID = %q, want reused %q", draft.ID, id)
212 }
213 }
214 a.mu.RLock()
215 visible, detached := len(a.tabs), len(a.detachedSessions)
216 a.mu.RUnlock()
217 if visible != 0 || detached != 0 {
218 t.Fatalf("runtime tabs = visible %d detached %d, want zero", visible, detached)
219 }
220 if entries, err := os.ReadDir(desktopSessionDir(root)); err == nil && len(entries) != 0 {
221 t.Fatalf("draft open created session files: %+v", entries)
222 }
223 }
224
225 func TestDraftsStayIsolatedAcrossWorkspaces(t *testing.T) {
226 a := newDraftTestApp(t)
227 rootA, rootB := t.TempDir(), t.TempDir()
228 draftA, err := a.OpenSessionDraftForTarget("project", rootA)
229 if err != nil {
230 t.Fatal(err)
231 }
232 draftB, err := a.OpenSessionDraftForTarget("project", rootB)
233 if err != nil {
234 t.Fatal(err)
235 }
236 if draftA.ID == draftB.ID {
237 t.Fatal("different workspaces reused one DraftID")
238 }
239 if _, err := a.SaveSessionDraft(SessionDraftSaveRequest{DraftID: draftA.ID, Revision: draftA.Revision, ContentJSON: `{"text":"A"}`, Settings: draftA.Settings}); err != nil {
240 t.Fatal(err)
241 }
242 if _, err := a.SaveSessionDraft(SessionDraftSaveRequest{DraftID: draftB.ID, Revision: draftB.Revision, ContentJSON: `{"text":"B"}`, Settings: draftB.Settings}); err != nil {
243 t.Fatal(err)
244 }
245 reopenedA, err := a.OpenSessionDraftForTarget("project", rootA)
246 if err != nil {
247 t.Fatal(err)
248 }
249 reopenedB, err := a.OpenSessionDraftForTarget("project", rootB)
250 if err != nil {
251 t.Fatal(err)
252 }
253 if reopenedA.ContentJSON != `{"text":"A"}` || reopenedB.ContentJSON != `{"text":"B"}` {
254 t.Fatalf("restored content = %s / %s", reopenedA.ContentJSON, reopenedB.ContentJSON)
255 }
256 }
257
258 func TestLateSaveReportsDiscardedWithoutRevivingDraft(t *testing.T) {
259 a := newDraftTestApp(t)
260 draft, err := a.OpenSessionDraftForTarget("project", t.TempDir())
261 if err != nil {
262 t.Fatal(err)
263 }
264 if err := a.DiscardSessionDraft(draft.ID, draft.Revision); err != nil {
265 t.Fatal(err)
266 }
267 result, err := a.SaveSessionDraft(SessionDraftSaveRequest{
268 DraftID: draft.ID, Revision: draft.Revision, ContentJSON: `{"text":"late"}`, Settings: draft.Settings,
269 })
270 if err != nil {
271 t.Fatal(err)
272 }
273 if result.Outcome != "discarded" || result.Draft.Status != "discarded" {
274 t.Fatalf("late save result = %+v, want discarded", result)
275 }
276 restored, err := a.draftStore().Get(t.Context(), draft.ID)
277 if err != nil {
278 t.Fatal(err)
279 }
280 if restored.Status != "discarded" || strings.Contains(restored.ContentJSON, "late") {
281 t.Fatalf("late save revived or overwrote draft: %+v", restored)
282 }
283 }
284
285 func TestMissingDraftAttachmentFailsBeforeSessionReservation(t *testing.T) {
286 a := newDraftTestApp(t)
287 root := t.TempDir()
288 draft, err := a.OpenSessionDraftForTarget("project", root)
289 if err != nil {
290 t.Fatal(err)
291 }
292 saved, err := a.SaveSessionDraft(SessionDraftSaveRequest{
293 DraftID: draft.ID, Revision: draft.Revision,
294 ContentJSON: `{"text":"inspect","attachments":[{"path":".reasonix/attachments/missing.txt"}]}`,
295 Settings: draft.Settings,
296 })
297 if err != nil {
298 t.Fatal(err)
299 }
300 if _, err := a.BeginDraftSubmission(SessionDraftSubmissionRequest{DraftID: draft.ID, Revision: saved.Draft.Revision, Display: "inspect", Input: "inspect @.reasonix/attachments/missing.txt"}); err == nil {
301 t.Fatal("missing attachment should reject submission")
302 }
303 if operations, err := a.draftStore().PendingOperations(a.bootContext()); err != nil || len(operations) != 0 {
304 t.Fatalf("operations after validation failure = %+v, err %v", operations, err)
305 }
306 if tabs := a.ListTabs(); len(tabs) != 0 {
307 t.Fatalf("tabs after validation failure = %+v", tabs)
308 }
309 }
310
311 func TestMissingDraftImageReturnsStableErrorWithoutHostPath(t *testing.T) {
312 a := newDraftTestApp(t)
313 root := t.TempDir()
314 draft, err := a.OpenSessionDraftForTarget("project", root)
315 if err != nil {
316 t.Fatal(err)
317 }
318 saved, err := a.SaveSessionDraft(SessionDraftSaveRequest{
319 DraftID: draft.ID, Revision: draft.Revision,
320 ContentJSON: `{"text":"inspect","attachments":[{"path":".reasonix/attachments/missing.png"}]}`,
321 Settings: draft.Settings,
322 })
323 if err != nil {
324 t.Fatal(err)
325 }
326 _, err = a.BeginDraftSubmission(SessionDraftSubmissionRequest{
327 DraftID: draft.ID, Revision: saved.Draft.Revision,
328 Display: "inspect", Input: "inspect @.reasonix/attachments/missing.png",
329 })
330 if err == nil || err.Error() != "reasonix_error:image_attachment_unreadable" {
331 t.Fatalf("BeginDraftSubmission() error = %v, want stable image failure", err)
332 }
333 if strings.Contains(err.Error(), root) {
334 t.Fatalf("bridge error exposed workspace root: %v", err)
335 }
336 if operations, loadErr := a.draftStore().PendingOperations(a.bootContext()); loadErr != nil || len(operations) != 0 {
337 t.Fatalf("operations after image validation failure = %+v, err %v", operations, loadErr)
338 }
339 if tabs := a.ListTabs(); len(tabs) != 0 {
340 t.Fatalf("tabs after image validation failure = %+v", tabs)
341 }
342 }
343
344 func TestInvalidDraftModelFailsBeforeSessionReservation(t *testing.T) {
345 isolateDesktopUserDirs(t)
346 a := newDraftTestApp(t)
347 root := t.TempDir()
348 draft, err := a.OpenSessionDraftForTarget("project", root)
349 if err != nil {
350 t.Fatal(err)
351 }
352 settings := draft.Settings
353 settings.Model = "removed-provider/removed-model"
354 settings.ModelSource = draftModelSourceExplicit
355 saved, err := a.SaveSessionDraft(SessionDraftSaveRequest{
356 DraftID: draft.ID, Revision: draft.Revision, ContentJSON: `{"text":"inspect"}`, Settings: settings,
357 })
358 if err != nil {
359 t.Fatal(err)
360 }
361 _, err = a.BeginDraftSubmission(SessionDraftSubmissionRequest{
362 DraftID: draft.ID, Revision: saved.Draft.Revision, Display: "inspect", Input: "inspect",
363 })
364 if !errors.Is(err, boot.ErrUnknownModel) {
365 t.Fatalf("BeginDraftSubmission() error = %v, want boot.ErrUnknownModel", err)
366 }
367 if operations, listErr := a.draftStore().PendingOperations(a.bootContext()); listErr != nil || len(operations) != 0 {
368 t.Fatalf("operations after invalid model = %+v, err %v", operations, listErr)
369 }
370 if tabs := a.ListTabs(); len(tabs) != 0 {
371 t.Fatalf("tabs after invalid model = %+v", tabs)
372 }
373 }
374
375 func TestDraftManagementCommandCannotCreateSession(t *testing.T) {
376 a := newDraftTestApp(t)
377 draft, err := a.OpenSessionDraftForTarget("project", t.TempDir())
378 if err != nil {
379 t.Fatal(err)
380 }
381 for _, input := range []string{"/new", "/compact", "/model provider/model", "/theme dark", "/mcp"} {
382 if _, err := a.BeginDraftSubmission(SessionDraftSubmissionRequest{
383 DraftID: draft.ID, Revision: draft.Revision, Display: input, Input: input,
384 }); err == nil {
385 t.Fatalf("%s created a draft submission", input)
386 }
387 }
388 if operations, err := a.draftStore().PendingOperations(a.bootContext()); err != nil || len(operations) != 0 {
389 t.Fatalf("management commands reserved operations = %+v, err %v", operations, err)
390 }
391 if tabs := a.ListTabs(); len(tabs) != 0 {
392 t.Fatalf("management commands created tabs = %+v", tabs)
393 }
394 }
395
396 func TestDraftContextProjectsConfiguredMCPWithoutController(t *testing.T) {
397 isolateDesktopUserDirs(t)
398 a := newDraftTestApp(t)
399 root := t.TempDir()
400 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(`
401 [[plugins]]
402 name = "fixture"
403 command = "fixture-mcp"
404 args = ["serve"]
405 `), 0o644); err != nil {
406 t.Fatal(err)
407 }
408 draft, err := a.OpenSessionDraftForTarget("project", root)
409 if err != nil {
410 t.Fatal(err)
411 }
412 context, err := a.GetDraftContext(draft.ID)
413 if err != nil {
414 t.Fatal(err)
415 }
416 if len(context.Servers) != 1 || context.Servers[0].Name != "fixture" || context.Servers[0].Command != "fixture-mcp" {
417 t.Fatalf("draft MCP projection = %+v", context.Servers)
418 }
419 if !context.Servers[0].Enabled || context.Servers[0].RuntimeState != "idle" {
420 t.Fatalf("draft MCP availability = %+v", context.Servers[0])
421 }
422 if tabs := a.ListTabs(); len(tabs) != 0 {
423 t.Fatalf("capability projection created runtime tabs: %+v", tabs)
424 }
425 }
426
427 func TestTargetedAttachmentsDoNotFollowActiveWorkspace(t *testing.T) {
428 a := newDraftTestApp(t)
429 rootA, rootB := t.TempDir(), t.TempDir()
430 draftA, err := a.OpenSessionDraftForTarget("project", rootA)
431 if err != nil {
432 t.Fatal(err)
433 }
434 draftB, err := a.OpenSessionDraftForTarget("project", rootB)
435 if err != nil {
436 t.Fatal(err)
437 }
438 payloadA := "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("attachment-a"))
439 payloadB := "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("attachment-b"))
440 targetA := ComposerTarget{Kind: "draft", DraftID: draftA.ID}
441 targetB := ComposerTarget{Kind: "draft", DraftID: draftB.ID}
442 var pathA, pathB string
443 var errA, errB error
444 var wg sync.WaitGroup
445 wg.Add(2)
446 go func() { defer wg.Done(); pathA, errA = a.SavePastedFileForComposerTarget(targetA, "a.txt", payloadA) }()
447 go func() { defer wg.Done(); pathB, errB = a.SavePastedFileForComposerTarget(targetB, "b.txt", payloadB) }()
448 wg.Wait()
449 if errA != nil || errB != nil {
450 t.Fatalf("targeted saves = %v / %v", errA, errB)
451 }
452 gotA, err := os.ReadFile(filepath.Join(rootA, filepath.FromSlash(pathA)))
453 if err != nil {
454 t.Fatal(err)
455 }
456 gotB, err := os.ReadFile(filepath.Join(rootB, filepath.FromSlash(pathB)))
457 if err != nil {
458 t.Fatal(err)
459 }
460 if string(gotA) != "attachment-a" || string(gotB) != "attachment-b" {
461 t.Fatalf("attachment contents = %q / %q", gotA, gotB)
462 }
463 if _, err := os.Stat(filepath.Join(rootB, filepath.FromSlash(pathA))); err == nil && pathA != pathB {
464 t.Fatalf("A attachment %q leaked into workspace B", pathA)
465 }
466 }
467
468 func TestReconcileDraftSubmissionOperationsNeverReplaysInterruptedDispatch(t *testing.T) {
469 for _, phase := range []string{"reserved", "starting", "dispatching", "dispatching_shell"} {
470 t.Run(phase, func(t *testing.T) {
471 a := newDraftTestApp(t)
472 _, op := beginDraftTestOperation(t, a, phase)
473 a.reconcileDraftSubmissionOperations()
474 got, err := a.draftStore().Operation(context.Background(), op.ID)
475 if err != nil {
476 t.Fatal(err)
477 }
478 want := "resume_required"
479 if phase == "dispatching" || phase == "dispatching_shell" {
480 want = "dispatch_unknown"
481 }
482 if got.Phase != want {
483 t.Fatalf("phase after restart = %q, want %q", got.Phase, want)
484 }
485 if tabs := a.ListTabs(); len(tabs) != 0 {
486 t.Fatalf("startup reconciliation created runtime tabs: %+v", tabs)
487 }
488 })
489 }
490 }
491
492 func TestReconcileAcceptedDraftCompletesConversion(t *testing.T) {
493 a := newDraftTestApp(t)
494 draft, op := beginDraftTestOperation(t, a, "accepted")
495 a.reconcileDraftSubmissionOperations()
496 got, err := a.draftStore().Get(context.Background(), draft.ID)
497 if err != nil {
498 t.Fatal(err)
499 }
500 if got.Status != "converted" {
501 t.Fatalf("draft status = %q, want converted", got.Status)
502 }
503 accepted, err := a.draftStore().Operation(context.Background(), op.ID)
504 if err != nil {
505 t.Fatal(err)
506 }
507 if accepted.Phase != "accepted" || accepted.SessionID != op.SessionID || accepted.TopicID != op.TopicID {
508 t.Fatalf("accepted operation identity changed: %+v", accepted)
509 }
510 }
511
512 func TestPersistedDraftCreateOperationKeepsIdentity(t *testing.T) {
513 tab := &WorkspaceTab{ID: "tab", SessionID: "session", PendingCreateOperationID: "operation"}
514 entry := persistedDesktopTabEntry(tab)
515 if entry.CreateOperationID != "operation" {
516 t.Fatalf("persisted create operation = %q", entry.CreateOperationID)
517 }
518 }
519
520 func TestDraftRetryReplacesOnlyTerminalWorkspaceReservation(t *testing.T) {
521 isolateDesktopUserDirs(t)
522 a := newDraftTestApp(t)
523 root := t.TempDir()
524 draft, err := a.OpenSessionDraftForTarget("project", root)
525 if err != nil {
526 t.Fatal(err)
527 }
528 old, _, err := a.draftStore().BeginOperation(t.Context(), draftstate.Operation{
529 ID: "old", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision,
530 SessionID: "session", TopicID: "topic", SubmissionID: "submission-old", Fingerprint: "old", RequestJSON: `{}`,
531 })
532 if err != nil {
533 t.Fatal(err)
534 }
535 if err := a.workspaceRegistry().BeginCreate(t.Context(), workspacestate.PendingCreate{OperationID: old.ID, WorkspaceID: draft.WorkspaceID, SessionID: old.SessionID}); err != nil {
536 t.Fatal(err)
537 }
538 if _, err := a.draftStore().SetOperationPhase(t.Context(), old.ID, "terminal_failed", "failed before bind"); err != nil {
539 t.Fatal(err)
540 }
541 next, created, err := a.draftStore().BeginOperation(t.Context(), draftstate.Operation{
542 ID: "new", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision,
543 SessionID: "different", TopicID: "different", SubmissionID: "submission-new", Fingerprint: "new", RequestJSON: `{}`,
544 })
545 if err != nil || !created {
546 t.Fatalf("retry operation = %+v, created %v, err %v", next, created, err)
547 }
548 if next.SessionID != old.SessionID {
549 t.Fatalf("retry session = %q, want %q", next.SessionID, old.SessionID)
550 }
551 if err := a.beginDraftWorkspaceCreate(next, draft.WorkspaceID); err != nil {
552 t.Fatalf("begin retry create: %v", err)
553 }
554 state, err := a.workspaceRegistry().Load(t.Context())
555 if err != nil {
556 t.Fatal(err)
557 }
558 if pending := state.PendingCreates[next.SessionID]; pending.OperationID != next.ID {
559 t.Fatalf("pending create = %+v, want retry operation", pending)
560 }
561 }
562
563 func TestDraftSubmissionRefusesArchivedReservedSession(t *testing.T) {
564 isolateDesktopUserDirs(t)
565 a := newDraftTestApp(t)
566 root := t.TempDir()
567 draft, err := a.OpenSessionDraftForTarget("project", root)
568 if err != nil {
569 t.Fatal(err)
570 }
571 op := draftstate.Operation{ID: "operation", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, SessionID: "session"}
572 if err := a.workspaceRegistry().AttachSession(t.Context(), "", draft.WorkspaceID, op.SessionID, ""); err != nil {
573 t.Fatal(err)
574 }
575 if err := a.workspaceRegistry().ArchiveSession(t.Context(), op.SessionID); err != nil {
576 t.Fatal(err)
577 }
578 if err := a.beginDraftWorkspaceCreate(op, draft.WorkspaceID); err == nil || !strings.Contains(err.Error(), "archived") {
579 t.Fatalf("archived create error = %v", err)
580 }
581 }
582
582 lines GO