返回 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: "draft-op-" + 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 approveWorkspace(t, rootA)
134 if err := os.WriteFile(filepath.Join(rootB, "reasonix.toml"), []byte(`default_model = "local/model-b"`), 0o644); err != nil {
135 t.Fatal(err)
136 }
137 approveWorkspace(t, rootB)
138 draftA, err := a.seedPreviousDraftForTarget("project", rootA)
139 if err != nil {
140 t.Fatal(err)
141 }
142 draftB, err := a.seedPreviousDraftForTarget("project", rootB)
143 if err != nil {
144 t.Fatal(err)
145 }
146 current := func(models []ModelInfo) string {
147 for _, model := range models {
148 if model.Current {
149 return model.Ref
150 }
151 }
152 return ""
153 }
154 if got := current(a.ModelsForDraft(draftA.ID)); got != "local/model-a" {
155 t.Fatalf("workspace A current model = %q, want local/model-a", got)
156 }
157 if got := current(a.ModelsForDraft(draftB.ID)); got != "local/model-b" {
158 t.Fatalf("workspace B current model = %q, want local/model-b", got)
159 }
160 }
161
162 func TestDraftRetryAppliesFrozenSettingsToReservedSession(t *testing.T) {
163 a := newDraftTestApp(t)
164 oldEffort := "low"
165 tab := &WorkspaceTab{
166 ID: "tab", SessionID: "session", PendingCreateOperationID: "old-operation",
167 model: "old/model", effort: &oldEffort, mode: "normal", toolApprovalMode: "ask",
168 disabledMCP: map[string]ServerView{}, mcpOrder: []string{"old"},
169 }
170 a.tabs[tab.ID] = tab
171 a.tabOrder = []string{tab.ID}
172 settings := SessionDraftSettings{
173 Model: "new/model", Effort: "high", QualityFloor: "high", CollaborationMode: "plan",
174 ToolApprovalMode: control.ToolApprovalDangerFullAccess,
175 DisabledMCP: map[string]ServerView{"disabled": {Name: "disabled"}}, MCPOrder: []string{"disabled"},
176 }
177 if err := a.applyDraftOperationSettings(draftstate.Operation{ID: "new-operation", SessionID: tab.SessionID}, settings); err != nil {
178 t.Fatal(err)
179 }
180 if tab.PendingCreateOperationID != "new-operation" || tab.model != "new/model" || tab.effort == nil || *tab.effort != "high" {
181 t.Fatalf("retry identity/model/effort = %q / %q / %+v", tab.PendingCreateOperationID, tab.model, tab.effort)
182 }
183 if tab.qualityFloor != "high" || !tabModeHasPlan(tab.mode) || normalizeToolApprovalMode(tab.toolApprovalMode) != control.ToolApprovalDangerFullAccess {
184 t.Fatalf("retry profile = quality %q mode %q approval %q", tab.qualityFloor, tab.mode, tab.toolApprovalMode)
185 }
186 if _, ok := tab.disabledMCP["disabled"]; !ok || len(tab.mcpOrder) != 1 || tab.mcpOrder[0] != "disabled" {
187 t.Fatalf("retry MCP profile = disabled %+v order %+v", tab.disabledMCP, tab.mcpOrder)
188 }
189 }
190
191 func containsAny(value string, needles ...string) bool {
192 for _, needle := range needles {
193 if strings.Contains(value, needle) {
194 return true
195 }
196 }
197 return false
198 }
199
200 func TestOpenSessionDraftDoesNotCreateRuntimeArtifacts(t *testing.T) {
201 a := newDraftTestApp(t)
202 root := t.TempDir()
203 var id string
204 for range 20 {
205 draft, err := a.seedPreviousDraftForTarget("project", root)
206 if err != nil {
207 t.Fatalf("OpenSessionDraftForTarget() error = %v", err)
208 }
209 if id == "" {
210 id = draft.ID
211 }
212 if draft.ID != id {
213 t.Fatalf("draft ID = %q, want reused %q", draft.ID, id)
214 }
215 }
216 a.mu.RLock()
217 visible, detached := len(a.tabs), len(a.detachedSessions)
218 a.mu.RUnlock()
219 if visible != 0 || detached != 0 {
220 t.Fatalf("runtime tabs = visible %d detached %d, want zero", visible, detached)
221 }
222 if entries, err := os.ReadDir(desktopSessionDir(root)); err == nil && len(entries) != 0 {
223 t.Fatalf("draft open created session files: %+v", entries)
224 }
225 }
226
227 func TestDraftsStayIsolatedAcrossWorkspaces(t *testing.T) {
228 a := newDraftTestApp(t)
229 rootA, rootB := t.TempDir(), t.TempDir()
230 draftA, err := a.seedPreviousDraftForTarget("project", rootA)
231 if err != nil {
232 t.Fatal(err)
233 }
234 draftB, err := a.seedPreviousDraftForTarget("project", rootB)
235 if err != nil {
236 t.Fatal(err)
237 }
238 if draftA.ID == draftB.ID {
239 t.Fatal("different workspaces reused one DraftID")
240 }
241 if _, err := a.SaveSessionDraft(SessionDraftSaveRequest{DraftID: draftA.ID, Revision: draftA.Revision, ContentJSON: `{"text":"A"}`, Settings: draftA.Settings}); err != nil {
242 t.Fatal(err)
243 }
244 if _, err := a.SaveSessionDraft(SessionDraftSaveRequest{DraftID: draftB.ID, Revision: draftB.Revision, ContentJSON: `{"text":"B"}`, Settings: draftB.Settings}); err != nil {
245 t.Fatal(err)
246 }
247 reopenedA, err := a.OpenSessionDraftForTarget("project", rootA)
248 if err != nil {
249 t.Fatal(err)
250 }
251 reopenedB, err := a.OpenSessionDraftForTarget("project", rootB)
252 if err != nil {
253 t.Fatal(err)
254 }
255 if reopenedA.ContentJSON != `{"text":"A"}` || reopenedB.ContentJSON != `{"text":"B"}` {
256 t.Fatalf("restored content = %s / %s", reopenedA.ContentJSON, reopenedB.ContentJSON)
257 }
258 }
259
260 func TestLateSaveReportsDiscardedWithoutRevivingDraft(t *testing.T) {
261 a := newDraftTestApp(t)
262 draft, err := a.seedPreviousDraftForTarget("project", t.TempDir())
263 if err != nil {
264 t.Fatal(err)
265 }
266 if err := a.DiscardSessionDraft(draft.ID, draft.Revision); err != nil {
267 t.Fatal(err)
268 }
269 result, err := a.SaveSessionDraft(SessionDraftSaveRequest{
270 DraftID: draft.ID, Revision: draft.Revision, ContentJSON: `{"text":"late"}`, Settings: draft.Settings,
271 })
272 if err != nil {
273 t.Fatal(err)
274 }
275 if result.Outcome != "discarded" || result.Draft.Status != "discarded" {
276 t.Fatalf("late save result = %+v, want discarded", result)
277 }
278 restored, err := a.draftStore().Get(t.Context(), draft.ID)
279 if err != nil {
280 t.Fatal(err)
281 }
282 if restored.Status != "discarded" || strings.Contains(restored.ContentJSON, "late") {
283 t.Fatalf("late save revived or overwrote draft: %+v", restored)
284 }
285 }
286
287 func TestMissingDraftAttachmentFailsBeforeSessionReservation(t *testing.T) {
288 a := newDraftTestApp(t)
289 root := t.TempDir()
290 draft, err := a.seedPreviousDraftForTarget("project", root)
291 if err != nil {
292 t.Fatal(err)
293 }
294 saved, err := a.SaveSessionDraft(SessionDraftSaveRequest{
295 DraftID: draft.ID, Revision: draft.Revision,
296 ContentJSON: `{"text":"inspect","attachments":[{"path":".reasonix/attachments/missing.txt"}]}`,
297 Settings: draft.Settings,
298 })
299 if err != nil {
300 t.Fatal(err)
301 }
302 if _, err := a.BeginDraftSubmission(SessionDraftSubmissionRequest{DraftID: draft.ID, Revision: saved.Draft.Revision, Display: "inspect", Input: "inspect @.reasonix/attachments/missing.txt"}); err == nil {
303 t.Fatal("missing attachment should reject submission")
304 }
305 if operations, err := a.draftStore().PendingOperations(a.bootContext()); err != nil || len(operations) != 0 {
306 t.Fatalf("operations after validation failure = %+v, err %v", operations, err)
307 }
308 if tabs := a.ListTabs(); len(tabs) != 0 {
309 t.Fatalf("tabs after validation failure = %+v", tabs)
310 }
311 }
312
313 func TestMissingDraftImageReturnsStableErrorWithoutHostPath(t *testing.T) {
314 a := newDraftTestApp(t)
315 root := t.TempDir()
316 draft, err := a.seedPreviousDraftForTarget("project", root)
317 if err != nil {
318 t.Fatal(err)
319 }
320 saved, err := a.SaveSessionDraft(SessionDraftSaveRequest{
321 DraftID: draft.ID, Revision: draft.Revision,
322 ContentJSON: `{"text":"inspect","attachments":[{"path":".reasonix/attachments/missing.png"}]}`,
323 Settings: draft.Settings,
324 })
325 if err != nil {
326 t.Fatal(err)
327 }
328 _, err = a.BeginDraftSubmission(SessionDraftSubmissionRequest{
329 DraftID: draft.ID, Revision: saved.Draft.Revision,
330 Display: "inspect", Input: "inspect @.reasonix/attachments/missing.png",
331 })
332 if err == nil || err.Error() != "reasonix_error:image_attachment_unreadable" {
333 t.Fatalf("BeginDraftSubmission() error = %v, want stable image failure", err)
334 }
335 if strings.Contains(err.Error(), root) {
336 t.Fatalf("bridge error exposed workspace root: %v", err)
337 }
338 if operations, loadErr := a.draftStore().PendingOperations(a.bootContext()); loadErr != nil || len(operations) != 0 {
339 t.Fatalf("operations after image validation failure = %+v, err %v", operations, loadErr)
340 }
341 if tabs := a.ListTabs(); len(tabs) != 0 {
342 t.Fatalf("tabs after image validation failure = %+v", tabs)
343 }
344 }
345
346 func TestInvalidDraftModelFailsBeforeSessionReservation(t *testing.T) {
347 isolateDesktopUserDirs(t)
348 a := newDraftTestApp(t)
349 root := t.TempDir()
350 draft, err := a.seedPreviousDraftForTarget("project", root)
351 if err != nil {
352 t.Fatal(err)
353 }
354 settings := draft.Settings
355 settings.Model = "removed-provider/removed-model"
356 settings.ModelSource = draftModelSourceExplicit
357 saved, err := a.SaveSessionDraft(SessionDraftSaveRequest{
358 DraftID: draft.ID, Revision: draft.Revision, ContentJSON: `{"text":"inspect"}`, Settings: settings,
359 })
360 if err != nil {
361 t.Fatal(err)
362 }
363 _, err = a.BeginDraftSubmission(SessionDraftSubmissionRequest{
364 DraftID: draft.ID, Revision: saved.Draft.Revision, Display: "inspect", Input: "inspect",
365 })
366 if !errors.Is(err, boot.ErrUnknownModel) {
367 t.Fatalf("BeginDraftSubmission() error = %v, want boot.ErrUnknownModel", err)
368 }
369 if operations, listErr := a.draftStore().PendingOperations(a.bootContext()); listErr != nil || len(operations) != 0 {
370 t.Fatalf("operations after invalid model = %+v, err %v", operations, listErr)
371 }
372 if tabs := a.ListTabs(); len(tabs) != 0 {
373 t.Fatalf("tabs after invalid model = %+v", tabs)
374 }
375 }
376
377 func TestDraftManagementCommandCannotCreateSession(t *testing.T) {
378 a := newDraftTestApp(t)
379 draft, err := a.seedPreviousDraftForTarget("project", t.TempDir())
380 if err != nil {
381 t.Fatal(err)
382 }
383 for _, input := range []string{"/new", "/compact", "/model provider/model", "/theme dark", "/mcp"} {
384 if _, err := a.BeginDraftSubmission(SessionDraftSubmissionRequest{
385 DraftID: draft.ID, Revision: draft.Revision, Display: input, Input: input,
386 }); err == nil {
387 t.Fatalf("%s created a draft submission", input)
388 }
389 }
390 if operations, err := a.draftStore().PendingOperations(a.bootContext()); err != nil || len(operations) != 0 {
391 t.Fatalf("management commands reserved operations = %+v, err %v", operations, err)
392 }
393 if tabs := a.ListTabs(); len(tabs) != 0 {
394 t.Fatalf("management commands created tabs = %+v", tabs)
395 }
396 }
397
398 func TestDraftContextProjectsConfiguredMCPWithoutController(t *testing.T) {
399 isolateDesktopUserDirs(t)
400 a := newDraftTestApp(t)
401 root := t.TempDir()
402 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(`
403 [[plugins]]
404 name = "fixture"
405 command = "fixture-mcp"
406 args = ["serve"]
407 `), 0o644); err != nil {
408 t.Fatal(err)
409 }
410 approveWorkspace(t, root)
411 enableProjectMCPForTest(t, root)
412 draft, err := a.seedPreviousDraftForTarget("project", root)
413 if err != nil {
414 t.Fatal(err)
415 }
416 context, err := a.GetDraftContext(draft.ID)
417 if err != nil {
418 t.Fatal(err)
419 }
420 if len(context.Servers) != 1 || context.Servers[0].Name != "fixture" || context.Servers[0].Command != "fixture-mcp" {
421 t.Fatalf("draft MCP projection = %+v", context.Servers)
422 }
423 if !context.Servers[0].Enabled || context.Servers[0].RuntimeState != "idle" {
424 t.Fatalf("draft MCP availability = %+v", context.Servers[0])
425 }
426 if tabs := a.ListTabs(); len(tabs) != 0 {
427 t.Fatalf("capability projection created runtime tabs: %+v", tabs)
428 }
429 }
430
431 func TestTargetedAttachmentsDoNotFollowActiveWorkspace(t *testing.T) {
432 a := newDraftTestApp(t)
433 rootA, rootB := t.TempDir(), t.TempDir()
434 draftA, err := a.seedPreviousDraftForTarget("project", rootA)
435 if err != nil {
436 t.Fatal(err)
437 }
438 draftB, err := a.seedPreviousDraftForTarget("project", rootB)
439 if err != nil {
440 t.Fatal(err)
441 }
442 payloadA := "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("attachment-a"))
443 payloadB := "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("attachment-b"))
444 targetA := ComposerTarget{Kind: "draft", DraftID: draftA.ID}
445 targetB := ComposerTarget{Kind: "draft", DraftID: draftB.ID}
446 var pathA, pathB string
447 var errA, errB error
448 var wg sync.WaitGroup
449 wg.Add(2)
450 go func() { defer wg.Done(); pathA, errA = a.SavePastedFileForComposerTarget(targetA, "a.txt", payloadA) }()
451 go func() { defer wg.Done(); pathB, errB = a.SavePastedFileForComposerTarget(targetB, "b.txt", payloadB) }()
452 wg.Wait()
453 if errA != nil || errB != nil {
454 t.Fatalf("targeted saves = %v / %v", errA, errB)
455 }
456 gotA, err := os.ReadFile(filepath.Join(rootA, filepath.FromSlash(pathA)))
457 if err != nil {
458 t.Fatal(err)
459 }
460 gotB, err := os.ReadFile(filepath.Join(rootB, filepath.FromSlash(pathB)))
461 if err != nil {
462 t.Fatal(err)
463 }
464 if string(gotA) != "attachment-a" || string(gotB) != "attachment-b" {
465 t.Fatalf("attachment contents = %q / %q", gotA, gotB)
466 }
467 if _, err := os.Stat(filepath.Join(rootB, filepath.FromSlash(pathA))); err == nil && pathA != pathB {
468 t.Fatalf("A attachment %q leaked into workspace B", pathA)
469 }
470 }
471
472 func TestReconcileDraftSubmissionOperationsNeverReplaysInterruptedDispatch(t *testing.T) {
473 for _, phase := range []string{"reserved", "starting", "dispatching", "dispatching_shell"} {
474 t.Run(phase, func(t *testing.T) {
475 a := newDraftTestApp(t)
476 _, op := beginDraftTestOperation(t, a, phase)
477 a.reconcileDraftSubmissionOperations()
478 got, err := a.draftStore().Operation(context.Background(), op.ID)
479 if err != nil {
480 t.Fatal(err)
481 }
482 want := "resume_required"
483 if phase == "dispatching" || phase == "dispatching_shell" {
484 want = "dispatch_unknown"
485 }
486 if got.Phase != want {
487 t.Fatalf("phase after restart = %q, want %q", got.Phase, want)
488 }
489 if tabs := a.ListTabs(); len(tabs) != 0 {
490 t.Fatalf("startup reconciliation created runtime tabs: %+v", tabs)
491 }
492 })
493 }
494 }
495
496 func TestReconcileAcceptedDraftCompletesConversion(t *testing.T) {
497 a := newDraftTestApp(t)
498 draft, op := beginDraftTestOperation(t, a, "accepted")
499 a.reconcileDraftSubmissionOperations()
500 got, err := a.draftStore().Get(context.Background(), draft.ID)
501 if err != nil {
502 t.Fatal(err)
503 }
504 if got.Status != "converted" {
505 t.Fatalf("draft status = %q, want converted", got.Status)
506 }
507 accepted, err := a.draftStore().Operation(context.Background(), op.ID)
508 if err != nil {
509 t.Fatal(err)
510 }
511 if accepted.Phase != "accepted" || accepted.SessionID != op.SessionID || accepted.TopicID != op.TopicID {
512 t.Fatalf("accepted operation identity changed: %+v", accepted)
513 }
514 }
515
516 func TestPersistedDraftCreateOperationKeepsIdentity(t *testing.T) {
517 tab := &WorkspaceTab{ID: "tab", SessionID: "session", PendingCreateOperationID: "operation"}
518 entry := persistedDesktopTabEntry(tab)
519 if entry.CreateOperationID != "operation" {
520 t.Fatalf("persisted create operation = %q", entry.CreateOperationID)
521 }
522 }
523
524 func TestDraftRetryReplacesOnlyTerminalWorkspaceReservation(t *testing.T) {
525 isolateDesktopUserDirs(t)
526 a := newDraftTestApp(t)
527 root := t.TempDir()
528 draft, err := a.seedPreviousDraftForTarget("project", root)
529 if err != nil {
530 t.Fatal(err)
531 }
532 old, _, err := a.draftStore().BeginOperation(t.Context(), draftstate.Operation{
533 ID: "old", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision,
534 SessionID: "session", TopicID: "topic", SubmissionID: "submission-old", Fingerprint: "old", RequestJSON: `{}`,
535 })
536 if err != nil {
537 t.Fatal(err)
538 }
539 if err := a.workspaceRegistry().BeginCreate(t.Context(), workspacestate.PendingCreate{OperationID: old.ID, WorkspaceID: draft.WorkspaceID, SessionID: old.SessionID}); err != nil {
540 t.Fatal(err)
541 }
542 if _, err := a.draftStore().SetOperationPhase(t.Context(), old.ID, "terminal_failed", "failed before bind"); err != nil {
543 t.Fatal(err)
544 }
545 next, created, err := a.draftStore().BeginOperation(t.Context(), draftstate.Operation{
546 ID: "new", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, DraftRevision: draft.Revision,
547 SessionID: "different", TopicID: "different", SubmissionID: "submission-new", Fingerprint: "new", RequestJSON: `{}`,
548 })
549 if err != nil || !created {
550 t.Fatalf("retry operation = %+v, created %v, err %v", next, created, err)
551 }
552 if next.SessionID != old.SessionID {
553 t.Fatalf("retry session = %q, want %q", next.SessionID, old.SessionID)
554 }
555 if err := a.beginDraftWorkspaceCreate(next, draft.WorkspaceID); err != nil {
556 t.Fatalf("begin retry create: %v", err)
557 }
558 state, err := a.workspaceRegistry().Load(t.Context())
559 if err != nil {
560 t.Fatal(err)
561 }
562 if pending := state.PendingCreates[next.SessionID]; pending.OperationID != next.ID {
563 t.Fatalf("pending create = %+v, want retry operation", pending)
564 }
565 }
566
567 func TestDraftSubmissionRefusesArchivedReservedSession(t *testing.T) {
568 isolateDesktopUserDirs(t)
569 a := newDraftTestApp(t)
570 root := t.TempDir()
571 draft, err := a.seedPreviousDraftForTarget("project", root)
572 if err != nil {
573 t.Fatal(err)
574 }
575 op := draftstate.Operation{ID: "operation", DraftID: draft.ID, WorkspaceID: draft.WorkspaceID, SessionID: "session"}
576 if err := a.workspaceRegistry().AttachSession(t.Context(), "", draft.WorkspaceID, op.SessionID, ""); err != nil {
577 t.Fatal(err)
578 }
579 if err := a.workspaceRegistry().ArchiveSession(t.Context(), op.SessionID); err != nil {
580 t.Fatal(err)
581 }
582 if err := a.beginDraftWorkspaceCreate(op, draft.WorkspaceID); err == nil || !strings.Contains(err.Error(), "archived") {
583 t.Fatalf("archived create error = %v", err)
584 }
585 }
586
586 lines GO