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