| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "path/filepath" |
| 7 | "reflect" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/desktop/internal/workspacestate" |
| 12 | "reasonix/internal/provider" |
| 13 | "reasonix/internal/session" |
| 14 | ) |
| 15 | |
| 16 | func TestIndependentForkResumesPublicationWithSameOperationWithoutResettingChoices(t *testing.T) { |
| 17 | for _, restart := range []bool{false, true} { |
| 18 | t.Run(map[bool]string{false: "same-process-retry", true: "restart-recovery"}[restart], func(t *testing.T) { independentForkPublicationRecovery(t, restart) }) |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | func independentForkPublicationRecovery(t *testing.T, restart bool) { |
| 23 | app, root, refs := canonicalOrganizationFixture(t, "parent", "sibling") |
| 24 | parentRef, siblingRef := refs["parent"], refs["sibling"] |
| 25 | parentKey := projectNodeSessionKey(ProjectNode{Session: &parentRef}) |
| 26 | siblingKey := projectNodeSessionKey(ProjectNode{Session: &siblingRef}) |
| 27 | if err := app.ReorderSessions("project", root, []string{parentKey, siblingKey}); err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | if err := app.SaveSessionGroups("project", root, []desktopGroup{{ID: "parent-group", Title: "Parent group", SessionKeys: []string{parentKey}}}); err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | service := app.desktopSessionService("") |
| 34 | parent, ok := service.Runtime(refs["parent"]) |
| 35 | if !ok { |
| 36 | t.Fatal("parent runtime missing") |
| 37 | } |
| 38 | payload, _ := json.Marshal(map[string]any{"message": provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "retained fork history"}}) |
| 39 | if _, err := parent.Session().Append(t.Context(), session.Batch{OperationID: "turn-1", TurnID: "turn-1", Events: []session.Event{ |
| 40 | {Kind: "turn/start"}, {Kind: "message/complete", Payload: payload}, {Kind: "turn/end", Payload: json.RawMessage(`{"status":"completed"}`)}, |
| 41 | }}); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | if _, err := parent.Session().Flush(t.Context()); err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | plan, err := app.planCanonicalFork(refs["parent"], "turn-1") |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | operationID, childID := "publication-retry", "publication-child" |
| 52 | // Force the real persistence -> registry publication race without timing or |
| 53 | // a production test hook: the captured plan is now one lifecycle behind. |
| 54 | if err := app.workspaceRegistry().ArchiveSession(t.Context(), refs["parent"].SessionID); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | if err := app.workspaceRegistry().RestoreSession(t.Context(), refs["parent"].SessionID); err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | if _, err := app.executeCanonicalFork(refs["parent"], plan, operationID, childID); !errors.Is(err, workspacestate.ErrMutationConflict) { |
| 61 | t.Fatalf("stale publication error = %v, want mutation conflict", err) |
| 62 | } |
| 63 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | if pending, ok := state.PendingCreates[childID]; !ok || pending.OperationID != operationID { |
| 68 | t.Fatalf("pending publication lost: %#v", state.PendingCreates) |
| 69 | } |
| 70 | childRef := session.SessionRef{HostID: "local", SessionID: childID} |
| 71 | if _, err := service.Query().Snapshot(t.Context(), childRef); err != nil { |
| 72 | t.Fatalf("failed publication lost durable child: %v", err) |
| 73 | } |
| 74 | if _, attached := state.SessionStates[childID]; attached { |
| 75 | t.Fatal("failed publication attached child") |
| 76 | } |
| 77 | if restart { |
| 78 | serviceRoot, registryPath := app.desktopSessions.root, app.workspaceRegistry().Path() |
| 79 | app.closeSessionServices() |
| 80 | app = NewApp() |
| 81 | app.ctx = t.Context() |
| 82 | app.desktopSessions.root = serviceRoot |
| 83 | app.desktopSessions.workspaceState = workspacestate.NewStore(registryPath) |
| 84 | t.Cleanup(app.closeSessionServices) |
| 85 | if err := app.recoverDesktopPendingCreates(t.Context()); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | state, err = app.workspaceRegistry().Load(t.Context()) |
| 89 | if err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | if want := []string{"parent", childID, "sibling"}; !reflect.DeepEqual(state.Workspaces[plan.workspaceID].SessionIDs, want) { |
| 93 | t.Fatalf("recovery order=%v want=%v", state.Workspaces[plan.workspaceID].SessionIDs, want) |
| 94 | } |
| 95 | organization, err := app.GetSessionOrganization(SessionOrganizationWorkspace{Scope: "project", WorkspaceRoot: root}) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | if len(organization.Groups) != 1 || !reflect.DeepEqual(organization.Groups[0].SessionKeys, []string{parentKey, projectNodeSessionKey(ProjectNode{Session: &childRef})}) { |
| 100 | t.Fatalf("recovery lost inherited group: %#v", organization.Groups) |
| 101 | } |
| 102 | } |
| 103 | plan, err = app.planCanonicalFork(refs["parent"], "turn-1") |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | child, err := app.executeCanonicalFork(refs["parent"], plan, operationID, childID) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | if child != childRef { |
| 112 | t.Fatalf("retry changed child identity: %#v", child) |
| 113 | } |
| 114 | if _, err := app.RenameSessionTarget(SessionSelector{Ref: &childRef}, "User chosen child title"); err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | if err := app.SaveSessionGroups("project", root, []desktopGroup{{ID: "chosen", Title: "Chosen", SessionKeys: []string{projectNodeSessionKey(ProjectNode{Session: &childRef})}}}); err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | if err := app.SetSessionPinned(SessionSelector{Ref: &childRef}, true); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | before, err := app.workspaceRegistry().Load(t.Context()) |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | if _, err := app.executeCanonicalFork(refs["parent"], plan, operationID, childID); err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | after, err := app.workspaceRegistry().Load(t.Context()) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if _, exists := after.PendingCreates[childID]; exists { |
| 135 | t.Fatal("retry left pending publication") |
| 136 | } |
| 137 | if !reflect.DeepEqual(before.Presentation[childID], after.Presentation[childID]) { |
| 138 | t.Fatalf("retry reset user presentation: %#v -> %#v", before.Presentation[childID], after.Presentation[childID]) |
| 139 | } |
| 140 | if !reflect.DeepEqual(before.Workspaces[plan.workspaceID].Organization, after.Workspaces[plan.workspaceID].Organization) { |
| 141 | t.Fatal("retry reset user group/order") |
| 142 | } |
| 143 | count := 0 |
| 144 | for _, id := range after.Workspaces[plan.workspaceID].SessionIDs { |
| 145 | if id == childID { |
| 146 | count++ |
| 147 | } |
| 148 | } |
| 149 | if count != 1 { |
| 150 | t.Fatalf("child membership count=%d", count) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestIndependentSessionForkRespectsManualSidebarOrder(t *testing.T) { |
| 155 | isolateDesktopUserDirs(t) |
| 156 | root := t.TempDir() |
| 157 | app := NewApp() |
| 158 | t.Cleanup(app.closeSessionServices) |
| 159 | app.ctx = t.Context() |
| 160 | app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id") |
| 161 | app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json")) |
| 162 | workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root) |
| 163 | if err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | if err := addProject(root, "Fork project"); err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | parent, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{ |
| 170 | SessionID: "target-fork-parent", CWD: root, Origin: session.SessionOriginNew, |
| 171 | }) |
| 172 | if err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | payload, err := json.Marshal(map[string]any{"message": provider.Message{ |
| 176 | ID: "answer", Role: provider.RoleAssistant, Content: "forked target history", |
| 177 | }}) |
| 178 | if err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | if _, err := parent.Session().Append(t.Context(), session.Batch{ |
| 182 | OperationID: "turn-1", TurnID: "turn-1", |
| 183 | Events: []session.Event{ |
| 184 | {Kind: "turn/start"}, |
| 185 | {Kind: "message/complete", Payload: payload}, |
| 186 | {Kind: "turn/end", Payload: json.RawMessage(`{"status":"completed"}`)}, |
| 187 | }, |
| 188 | }); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | if _, err := parent.Session().Flush(t.Context()); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | if err := app.desktopSessions.workspaceState.AttachSession(t.Context(), "", workspaceID, parent.Ref().SessionID, ""); err != nil { |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | if err := app.desktopSessionService("").SetTitle(t.Context(), parent.Ref(), "Parent work"); err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | parentTitle, parentPinned := "Parent work", true |
| 201 | if err := app.workspaceRegistry().UpdatePresentation(t.Context(), []string{parent.Ref().SessionID}, &parentTitle, &parentPinned); err != nil { |
| 202 | t.Fatal(err) |
| 203 | } |
| 204 | parentRef := parent.Ref() |
| 205 | if err := app.SaveSessionGroups("project", root, []desktopGroup{{ |
| 206 | ID: "feature", Title: "Feature", SessionKeys: []string{projectNodeSessionKey(ProjectNode{Session: &parentRef})}, |
| 207 | }}); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | app.tabs = map[string]*WorkspaceTab{"active": {ID: "active", SessionID: "unrelated"}} |
| 211 | app.activeTabID = "active" |
| 212 | |
| 213 | sibling, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{SessionID: "sibling", CWD: root, Origin: session.SessionOriginNew}) |
| 214 | if err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, sibling.Ref().SessionID, ""); err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | parentKey := projectNodeSessionKey(ProjectNode{Session: &parentRef}) |
| 221 | siblingRef := sibling.Ref() |
| 222 | siblingKey := projectNodeSessionKey(ProjectNode{Session: &siblingRef}) |
| 223 | if err := app.ReorderSessions("project", root, []string{parentKey, siblingKey}); err != nil { |
| 224 | t.Fatal(err) |
| 225 | } |
| 226 | |
| 227 | selector := SessionSelector{Ref: &session.SessionRef{HostID: localDesktopHostID, SessionID: parent.Ref().SessionID}} |
| 228 | first, err := app.ForkSessionTarget(selector, "turn-1") |
| 229 | if err != nil { |
| 230 | t.Fatal(err) |
| 231 | } |
| 232 | |
| 233 | page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 10}) |
| 234 | if err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | var got []string |
| 238 | for _, row := range page.Items { |
| 239 | if row.Session != nil { |
| 240 | got = append(got, row.Session.SessionID) |
| 241 | } |
| 242 | } |
| 243 | want := []string{parent.Ref().SessionID, first.SessionID, "sibling"} |
| 244 | if !reflect.DeepEqual(got, want) { |
| 245 | t.Fatalf("sidebar order = %v; want parent, child, sibling = %v", got, want) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | func TestIndependentSessionOrderRejectsStaleCursor(t *testing.T) { |
| 250 | isolateDesktopUserDirs(t) |
| 251 | root := t.TempDir() |
| 252 | app := NewApp() |
| 253 | t.Cleanup(app.closeSessionServices) |
| 254 | app.ctx = t.Context() |
| 255 | app.desktopSessions.root = filepath.Join(root, "by-id") |
| 256 | app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "state.json")) |
| 257 | ws, err := app.ensureDesktopWorkspace(t.Context(), "project", root) |
| 258 | if err != nil { |
| 259 | t.Fatal(err) |
| 260 | } |
| 261 | if err := addProject(root, "Review"); err != nil { |
| 262 | t.Fatal(err) |
| 263 | } |
| 264 | var keys []string |
| 265 | for _, id := range []string{"a", "b", "c"} { |
| 266 | runtime, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{SessionID: id, CWD: root, Origin: session.SessionOriginNew}) |
| 267 | if err != nil { |
| 268 | t.Fatal(err) |
| 269 | } |
| 270 | if err := app.workspaceRegistry().AttachSession(t.Context(), "", ws, id, ""); err != nil { |
| 271 | t.Fatal(err) |
| 272 | } |
| 273 | ref := runtime.Ref() |
| 274 | keys = append(keys, projectNodeSessionKey(ProjectNode{Session: &ref})) |
| 275 | } |
| 276 | if err := app.ReorderSessions("project", root, keys); err != nil { |
| 277 | t.Fatal(err) |
| 278 | } |
| 279 | first, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 1}) |
| 280 | if err != nil { |
| 281 | t.Fatal(err) |
| 282 | } |
| 283 | if first.NextCursor == "" || len(first.Items) != 1 || first.Items[0].Session == nil || first.Items[0].Session.SessionID != "a" { |
| 284 | t.Fatalf("first page = %#v; want A and a next-page cursor", first) |
| 285 | } |
| 286 | if err := app.ReorderSessions("project", root, []string{keys[2], keys[0], keys[1]}); err != nil { |
| 287 | t.Fatal(err) |
| 288 | } |
| 289 | second, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 1, Cursor: first.NextCursor}) |
| 290 | if err == nil { |
| 291 | t.Fatalf("stale cursor %q accepted: first=%#v second=%#v", first.NextCursor, first, second) |
| 292 | } |
| 293 | if !strings.Contains(err.Error(), "stale_cursor") { |
| 294 | t.Fatalf("stale cursor error = %v; want typed stale_cursor", err) |
| 295 | } |
| 296 | refreshed, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 10}) |
| 297 | if err != nil { |
| 298 | t.Fatal(err) |
| 299 | } |
| 300 | var got []string |
| 301 | for _, row := range refreshed.Items { |
| 302 | if row.Session != nil { |
| 303 | got = append(got, row.Session.SessionID) |
| 304 | } |
| 305 | } |
| 306 | if want := []string{"c", "a", "b"}; !reflect.DeepEqual(got, want) { |
| 307 | t.Fatalf("refreshed order = %v, want %v", got, want) |
| 308 | } |
| 309 | } |
| 310 |