| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/http" |
| 7 | "strconv" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/session" |
| 12 | ) |
| 13 | |
| 14 | // resumeEntryIndex returns the 1-based /resume index of the row matching the |
| 15 | // predicate, or 0 when absent. |
| 16 | func resumeEntryIndex(entries []resumeEntry, match func(resumeEntry) bool) int { |
| 17 | for i, entry := range entries { |
| 18 | if match(entry) { |
| 19 | return i + 1 |
| 20 | } |
| 21 | } |
| 22 | return 0 |
| 23 | } |
| 24 | |
| 25 | func assertMirrorLeft(t *testing.T, m *chatTUI, fake *fakeCanonicalServe, route string) { |
| 26 | t.Helper() |
| 27 | if binding, _, _, _ := m.takeover.snapshot(); binding != nil { |
| 28 | t.Fatalf("mirror binding still active after leaving %q: %+v", route, binding) |
| 29 | } |
| 30 | if ends := fake.mirrorEnds(); len(ends) != 1 || ends[0] != route { |
| 31 | t.Fatalf("mirror-end requests = %v, want exactly one for %q", ends, route) |
| 32 | } |
| 33 | if m.takeover.Returned() { |
| 34 | t.Fatal("switching away from a mirror left the manager in returned state") |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // TestResumeCanonicalSessionReturnsActiveCanonicalMirror covers /resume to a |
| 39 | // final-format session while this CLI mirrors another canonical identity for |
| 40 | // the desktop: the mirror must end so the desktop tab regains its writer and |
| 41 | // stops receiving the next session's frames under the old mirror id. |
| 42 | func TestResumeCanonicalSessionReturnsActiveCanonicalMirror(t *testing.T) { |
| 43 | route := cliCanonicalRoute("held") |
| 44 | fake := newFakeCanonicalServe(t, route) |
| 45 | withFakeCanonicalDiscovery(t, fake.base) |
| 46 | m, ctrl, _, held := newCanonicalTakeoverTUI(t) |
| 47 | m.runCanonicalTakeoverCommand(route) |
| 48 | if ref, bound := ctrl.SessionRef(); !bound || ref != held { |
| 49 | t.Fatalf("takeover did not attach: %+v bound=%v", ref, bound) |
| 50 | } |
| 51 | createCanonicalTestSession(t, session.RootForLegacyDir(ctrl.SessionDir()), "third", "third conversation") |
| 52 | |
| 53 | idx := resumeEntryIndex(resumeEntries(ctrl.SessionDir()), func(entry resumeEntry) bool { |
| 54 | return entry.target.canonical() && entry.target.ref.SessionID == "third" |
| 55 | }) |
| 56 | if idx == 0 { |
| 57 | t.Fatal("third canonical session missing from /resume list") |
| 58 | } |
| 59 | m.runResumeCommand("/resume " + strconv.Itoa(idx)) |
| 60 | |
| 61 | if ref, bound := ctrl.SessionRef(); !bound || ref.SessionID != "third" { |
| 62 | t.Fatalf("controller after /resume = %+v bound=%v, want third", ref, bound) |
| 63 | } |
| 64 | assertMirrorLeft(t, m, fake, route) |
| 65 | } |
| 66 | |
| 67 | // TestCanonicalTakeoverOfSecondIdentityReturnsFirstMirror covers /takeover of |
| 68 | // a second final-format identity while the first is still mirrored: Activate |
| 69 | // must not overwrite the live binding without ending the first mirror. |
| 70 | func TestCanonicalTakeoverOfSecondIdentityReturnsFirstMirror(t *testing.T) { |
| 71 | routeA, routeB := cliCanonicalRoute("held"), cliCanonicalRoute("second") |
| 72 | fake := newFakeCanonicalServeRoutes(t, routeA, routeB) |
| 73 | withFakeCanonicalDiscovery(t, fake.base) |
| 74 | m, ctrl, service, held := newCanonicalTakeoverTUI(t) |
| 75 | second, err := service.Create(t.Context(), session.CreateOptions{SessionID: "second"}) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | if err := service.Close(t.Context(), second.Ref()); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | m.runCanonicalTakeoverCommand(routeA) |
| 83 | if ref, bound := ctrl.SessionRef(); !bound || ref != held { |
| 84 | t.Fatalf("first takeover did not attach: %+v bound=%v", ref, bound) |
| 85 | } |
| 86 | |
| 87 | m.runCanonicalTakeoverCommand(routeB) |
| 88 | |
| 89 | if ref, bound := ctrl.SessionRef(); !bound || ref != second.Ref() { |
| 90 | t.Fatalf("controller after second takeover = %+v bound=%v, want second", ref, bound) |
| 91 | } |
| 92 | binding, _, _, _ := m.takeover.snapshot() |
| 93 | if binding == nil || binding.path != routeB || !binding.canonical { |
| 94 | t.Fatalf("mirror binding = %+v, want the second identity %q", binding, routeB) |
| 95 | } |
| 96 | if ends := fake.mirrorEnds(); len(ends) != 1 || ends[0] != routeA { |
| 97 | t.Fatalf("mirror-end requests = %v, want exactly one for the first identity %q", ends, routeA) |
| 98 | } |
| 99 | if fake.handoffCount() != 2 { |
| 100 | t.Fatalf("handoff requests = %d, want one per takeover", fake.handoffCount()) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // TestResumeLegacySessionReturnsActiveCanonicalMirror covers /resume to a |
| 105 | // legacy transcript while mirroring a canonical identity. The live keeper holds |
| 106 | // no lease after a canonical attach, so the detached source keeper carries no |
| 107 | // reverse reservation to publish; the switch must still end the mirror instead |
| 108 | // of failing with "no detached session lease held" or leaking it. |
| 109 | func TestResumeLegacySessionReturnsActiveCanonicalMirror(t *testing.T) { |
| 110 | route := cliCanonicalRoute("held") |
| 111 | fake := newFakeCanonicalServe(t, route) |
| 112 | withFakeCanonicalDiscovery(t, fake.base) |
| 113 | m, ctrl, _, held := newCanonicalTakeoverTUI(t) |
| 114 | m.runCanonicalTakeoverCommand(route) |
| 115 | if ref, bound := ctrl.SessionRef(); !bound || ref != held { |
| 116 | t.Fatalf("takeover did not attach: %+v bound=%v", ref, bound) |
| 117 | } |
| 118 | legacy := saveQueryTestSession(t, ctrl.SessionDir(), "legacy-target.jsonl", "LEGACY-TARGET-PROMPT") |
| 119 | |
| 120 | idx := resumeEntryIndex(resumeEntries(ctrl.SessionDir()), func(entry resumeEntry) bool { |
| 121 | return !entry.target.canonical() && entry.target.path == legacy |
| 122 | }) |
| 123 | if idx == 0 { |
| 124 | t.Fatal("legacy transcript missing from /resume list") |
| 125 | } |
| 126 | m.runResumeCommand("/resume " + strconv.Itoa(idx)) |
| 127 | |
| 128 | ref, bound := ctrl.SessionRef() |
| 129 | if !bound || ref == held { |
| 130 | t.Fatalf("controller after /resume = %+v bound=%v, want the imported legacy transcript", ref, bound) |
| 131 | } |
| 132 | loaded := false |
| 133 | for _, msg := range ctrl.History() { |
| 134 | loaded = loaded || msg.Content == "LEGACY-TARGET-PROMPT" |
| 135 | } |
| 136 | if !loaded { |
| 137 | t.Fatal("history not loaded from the legacy target") |
| 138 | } |
| 139 | assertMirrorLeft(t, m, fake, route) |
| 140 | } |
| 141 | |
| 142 | // TestResumeCanonicalSessionReturnsLegacyMirrorReservation covers the legacy |
| 143 | // half of the leave step: switching from a mirrored legacy transcript to a |
| 144 | // final-format session publishes the transcript's reverse reservation for the |
| 145 | // serve and ends the mirror before the controller publishes the new identity. |
| 146 | func TestResumeCanonicalSessionReturnsLegacyMirrorReservation(t *testing.T) { |
| 147 | m, ctrl, service, _ := newCanonicalTakeoverTUI(t) |
| 148 | legacy := saveQueryTestSession(t, ctrl.SessionDir(), "mirrored-legacy.jsonl", "mirrored legacy") |
| 149 | if err := m.leases.Rebind(legacy); err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | fake := newFakeCanonicalServe(t, legacy) |
| 153 | m.takeover.AttachController(ctrl) |
| 154 | m.takeover.Activate(&cliTakeoverBinding{ |
| 155 | path: legacy, record: cliServeRecord{base: fake.base}, client: &http.Client{}, |
| 156 | grant: cliTakeoverGrant{MirrorID: "mirror-legacy", SourceWriterID: "serve-writer", ReturnHandoffID: "return-legacy"}, |
| 157 | }) |
| 158 | third, err := service.Create(t.Context(), session.CreateOptions{SessionID: "third"}) |
| 159 | if err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | if err := service.Close(t.Context(), third.Ref()); err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | |
| 166 | if err := m.commitCanonicalSessionSwitch(third.Ref()); err != nil { |
| 167 | t.Fatalf("commitCanonicalSessionSwitch: %v", err) |
| 168 | } |
| 169 | |
| 170 | if ref, bound := ctrl.SessionRef(); !bound || ref != third.Ref() { |
| 171 | t.Fatalf("controller after switch = %+v bound=%v, want third", ref, bound) |
| 172 | } |
| 173 | assertMirrorLeft(t, m, fake, legacy) |
| 174 | info, err := agent.LoadSessionLeaseInfo(legacy) |
| 175 | if err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | if info == nil || info.HandoffTo != "serve-writer" || info.HandoffID != "return-legacy" { |
| 179 | t.Fatalf("legacy reverse reservation = %+v, want the serve's return handoff", info) |
| 180 | } |
| 181 | if held := m.leases.HeldPath(); held != "" { |
| 182 | t.Fatalf("keeper still holds %q after handing the legacy transcript back", held) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // TestResumeCanonicalSessionHeldElsewhereKeepsMirror pins failure atomicity of |
| 187 | // the canonical switch: when the target's writer belongs to another runtime, |
| 188 | // the current mirror, controller binding, and lease stay exactly as they were. |
| 189 | func TestResumeCanonicalSessionHeldElsewhereKeepsMirror(t *testing.T) { |
| 190 | route := cliCanonicalRoute("held") |
| 191 | fake := newFakeCanonicalServe(t, route) |
| 192 | withFakeCanonicalDiscovery(t, fake.base) |
| 193 | m, ctrl, _, held := newCanonicalTakeoverTUI(t) |
| 194 | m.runCanonicalTakeoverCommand(route) |
| 195 | other, err := session.NewService("local", session.NewFilesystemPersistence(session.RootForLegacyDir(ctrl.SessionDir()))) |
| 196 | if err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | t.Cleanup(func() { _ = other.Shutdown(context.Background()) }) |
| 200 | busy, err := other.Create(t.Context(), session.CreateOptions{SessionID: "busy"}) |
| 201 | if err != nil { |
| 202 | t.Fatal(err) |
| 203 | } |
| 204 | |
| 205 | err = m.commitCanonicalSessionSwitch(session.SessionRef{HostID: busy.Ref().HostID, SessionID: "busy"}) |
| 206 | |
| 207 | if !errors.Is(err, session.ErrWriterOwned) { |
| 208 | t.Fatalf("switch to a held session returned %v, want ErrWriterOwned", err) |
| 209 | } |
| 210 | if ref, bound := ctrl.SessionRef(); !bound || ref != held { |
| 211 | t.Fatalf("controller moved to %+v (bound %v) despite the refused switch", ref, bound) |
| 212 | } |
| 213 | binding, _, _, _ := m.takeover.snapshot() |
| 214 | if binding == nil || binding.path != route { |
| 215 | t.Fatalf("mirror binding = %+v after a refused switch, want %q still active", binding, route) |
| 216 | } |
| 217 | if ends := fake.mirrorEnds(); len(ends) != 0 { |
| 218 | t.Fatalf("mirror-end requests = %v after a refused switch, want none", ends) |
| 219 | } |
| 220 | } |
| 221 |