返回 DeepSeek-Reasonix
session_takeover_mirror_test.go
根目录 / internal / cli / session_takeover_mirror_test.go
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
221 lines GO