返回 DeepSeek-Reasonix
session_takeover_canonical_test.go
根目录 / internal / cli / session_takeover_canonical_test.go
1 package cli
2
3 import (
4 "context"
5 "encoding/json"
6 "maps"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "strconv"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "testing"
16 "time"
17
18 tea "charm.land/bubbletea/v2"
19
20 "reasonix/internal/agent"
21 "reasonix/internal/control"
22 "reasonix/internal/event"
23 "reasonix/internal/i18n"
24 "reasonix/internal/provider"
25 "reasonix/internal/session"
26 )
27
28 // newCanonicalTakeoverTUI builds an exclusive-session TUI over a fresh
29 // sessions-v4 catalog sharing the CLI's cached session service, plus a second
30 // ("held") identity the fake serve pretends to release.
31 func newCanonicalTakeoverTUI(t *testing.T) (*chatTUI, *control.Controller, *session.Service, session.SessionRef) {
32 t.Helper()
33 dir := t.TempDir()
34 sessionDir := filepath.Join(dir, "sessions")
35 if err := os.MkdirAll(sessionDir, 0o700); err != nil {
36 t.Fatal(err)
37 }
38 service := cliSessionService(sessionDir)
39 if service == nil {
40 t.Fatal("session service unavailable for test workspace")
41 }
42 ctrl := newOwnedTestController(t, control.Options{
43 Executor: agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard),
44 SessionDir: sessionDir, SessionService: service, ExclusiveSession: true,
45 })
46 if _, err := ctrl.BindFreshSession(t.Context(), "fresh-cli"); err != nil {
47 t.Fatal(err)
48 }
49 held, err := service.Create(t.Context(), session.CreateOptions{SessionID: "held"})
50 if err != nil {
51 t.Fatal(err)
52 }
53 if err := service.Close(t.Context(), held.Ref()); err != nil {
54 t.Fatal(err)
55 }
56 m := newTestChatTUI()
57 m.ctrl = ctrl
58 m.leases = control.NewSessionLeaseKeeper()
59 t.Cleanup(m.leases.Release)
60 m.takeover = newCLITakeoverManager(nil, m.leases)
61 t.Cleanup(func() {
62 ctrl.Close()
63 _ = service.CloseAll(context.Background())
64 })
65 // Registered last so it runs first: the manager's loop reads the serve
66 // discovery seam that withFakeCanonicalDiscovery restores on cleanup, so a
67 // loop outliving its test races the next test's fixture.
68 takeover := m.takeover
69 t.Cleanup(func() { _ = takeover.Close() })
70 return &m, ctrl, service, held.Ref()
71 }
72
73 // fakeCanonicalServe impersonates the resident serve: it grants the identity
74 // handoff, accepts mirrored frames, and can flag a reclaim.
75 type fakeCanonicalServe struct {
76 mu sync.Mutex
77 base string
78 handoffBody map[string]any
79 // routes, when set, lists every route the serve grants; the grant echoes
80 // the requested route and a distinct mirror id. Unlisted routes are
81 // refused like a serve that does not hold the session.
82 routes map[string]bool
83 handoffs int
84 framesPath []string
85 mirrorEnd []string
86 reclaim atomic.Bool
87 }
88
89 func newFakeCanonicalServe(t *testing.T, route string) *fakeCanonicalServe {
90 t.Helper()
91 return newFakeCanonicalServeRoutes(t, route)
92 }
93
94 func newFakeCanonicalServeRoutes(t *testing.T, routes ...string) *fakeCanonicalServe {
95 t.Helper()
96 f := &fakeCanonicalServe{}
97 f.handoffBody = map[string]any{
98 "sessionPath": routes[0], "mirrorId": "mirror-1", "handoffId": "handoff-1",
99 "returnHandoffId": "return-1", "sourceWriterId": "serve-writer",
100 "targetWriterId": agent.SessionWriterID(), "status": "handed_off",
101 }
102 if len(routes) > 1 {
103 f.routes = make(map[string]bool, len(routes))
104 for _, route := range routes {
105 f.routes[route] = true
106 }
107 }
108 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
109 switch r.URL.Path {
110 case "/auth/token":
111 w.WriteHeader(http.StatusNoContent)
112 case "/handoff":
113 var body map[string]any
114 _ = json.NewDecoder(r.Body).Decode(&body)
115 requested, _ := body["sessionPath"].(string)
116 f.mu.Lock()
117 f.handoffs++
118 f.handoffBody["__received"] = body
119 response := make(map[string]any, len(f.handoffBody))
120 maps.Copy(response, f.handoffBody)
121 if f.routes != nil {
122 if !f.routes[requested] {
123 f.mu.Unlock()
124 http.Error(w, "session is not held by this serve process", http.StatusConflict)
125 return
126 }
127 response["sessionPath"] = requested
128 response["mirrorId"] = "mirror-" + strconv.Itoa(f.handoffs)
129 }
130 f.mu.Unlock()
131 _ = json.NewEncoder(w).Encode(response)
132 case "/external/frames":
133 var body struct {
134 SessionPath string `json:"sessionPath"`
135 MirrorID string `json:"mirrorId"`
136 }
137 _ = json.NewDecoder(r.Body).Decode(&body)
138 f.mu.Lock()
139 f.framesPath = append(f.framesPath, body.SessionPath)
140 f.mu.Unlock()
141 _ = json.NewEncoder(w).Encode(map[string]any{"reclaimRequested": f.reclaim.Load(), "reclaimMode": "wait"})
142 case "/mirror-end":
143 var body struct {
144 SessionPath string `json:"sessionPath"`
145 }
146 _ = json.NewDecoder(r.Body).Decode(&body)
147 f.mu.Lock()
148 f.mirrorEnd = append(f.mirrorEnd, body.SessionPath)
149 f.mu.Unlock()
150 w.WriteHeader(http.StatusNoContent)
151 default:
152 w.WriteHeader(http.StatusNotFound)
153 }
154 }))
155 t.Cleanup(srv.Close)
156 f.base = srv.URL
157 return f
158 }
159
160 func (f *fakeCanonicalServe) mirrorEnds() []string {
161 f.mu.Lock()
162 defer f.mu.Unlock()
163 return append([]string(nil), f.mirrorEnd...)
164 }
165
166 func (f *fakeCanonicalServe) handoffCount() int {
167 f.mu.Lock()
168 defer f.mu.Unlock()
169 return f.handoffs
170 }
171
172 func withFakeCanonicalDiscovery(t *testing.T, base string) {
173 t.Helper()
174 previous := discoverCLIServesForTakeover
175 discoverCLIServesForTakeover = func() []cliServeRecord {
176 return []cliServeRecord{{pid: 1, base: base, token: "test-token"}}
177 }
178 t.Cleanup(func() { discoverCLIServesForTakeover = previous })
179 }
180
181 // TestCanonicalTakeoverCommandTakesOverIdentity proves the /takeover command
182 // against an identity route: the grant is validated, the controller attaches
183 // through OpenSession, and the mirror manager activates on the route key.
184 func TestCanonicalTakeoverCommandTakesOverIdentity(t *testing.T) {
185 route := cliCanonicalRoute("held")
186 fake := newFakeCanonicalServe(t, route)
187 withFakeCanonicalDiscovery(t, fake.base)
188 m, ctrl, _, held := newCanonicalTakeoverTUI(t)
189
190 m.runCanonicalTakeoverCommand(route)
191
192 if got := m.pendingTakeoverPath; got != "" {
193 t.Fatalf("pending takeover target = %q after success", got)
194 }
195 if ref, bound := ctrl.SessionRef(); !bound || ref != held {
196 t.Fatalf("controller ref after takeover = %+v (bound %v), want %+v", ref, bound, held)
197 }
198 binding, _, _, _ := m.takeover.snapshot()
199 if binding == nil || binding.path != route || !binding.canonical {
200 t.Fatalf("mirror binding = %+v, want canonical route %q", binding, route)
201 }
202 fake.mu.Lock()
203 received, _ := fake.handoffBody["__received"].(map[string]any)
204 fake.mu.Unlock()
205 if received == nil || received["sessionPath"] != route || received["targetWriterId"] != agent.SessionWriterID() {
206 t.Fatalf("handoff request = %+v", received)
207 }
208 if err := m.takeover.Close(); err != nil {
209 t.Fatal(err)
210 }
211 }
212
213 // TestCanonicalTakeoverCommandReportsRefusedGrant proves a refusing serve
214 // surfaces the failure without touching the controller's session.
215 func TestCanonicalTakeoverCommandReportsRefusedGrant(t *testing.T) {
216 route := cliCanonicalRoute("held")
217 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
218 switch r.URL.Path {
219 case "/auth/token":
220 w.WriteHeader(http.StatusNoContent)
221 default:
222 http.Error(w, "session is not held by this serve process", http.StatusConflict)
223 }
224 }))
225 defer srv.Close()
226 withFakeCanonicalDiscovery(t, srv.URL)
227 m, ctrl, _, held := newCanonicalTakeoverTUI(t)
228 // Model the refusal that matters: another runtime really owns the writer,
229 // so the refused grant is the only way in.
230 holder, err := session.NewService("local", session.NewFilesystemPersistence(session.RootForLegacyDir(ctrl.SessionDir())))
231 if err != nil {
232 t.Fatal(err)
233 }
234 // Shutdown, not CloseAll: the binding below is never unbound, and only the
235 // terminal boundary releases its writer lease before TempDir removal.
236 // Windows refuses to unlink a still-open ownership lock.
237 t.Cleanup(func() { _ = holder.Shutdown(context.Background()) })
238 if _, err := holder.Open(t.Context(), held); err != nil {
239 t.Fatal(err)
240 }
241
242 m.runCanonicalTakeoverCommand(route)
243
244 if ref, bound := ctrl.SessionRef(); !bound || ref.SessionID != "fresh-cli" {
245 t.Fatalf("controller ref after refused takeover = %+v (bound %v), want the original fresh session", ref, bound)
246 }
247 if binding, _, _, _ := m.takeover.snapshot(); binding != nil {
248 t.Fatalf("mirror binding activated despite refusal: %+v", binding)
249 }
250 }
251
252 // TestCanonicalReclaimYieldsWithoutLegacyLease proves the yield half: a
253 // reclaim signal against a canonical binding returns the mirror without any
254 // legacy path-lease reservation, and mirror-end carries the identity route.
255 func TestCanonicalReclaimYieldsWithoutLegacyLease(t *testing.T) {
256 route := cliCanonicalRoute("held")
257 fake := newFakeCanonicalServe(t, route)
258 m, ctrl, _, held := newCanonicalTakeoverTUI(t)
259 withFakeCanonicalDiscovery(t, fake.base)
260 m.runCanonicalTakeoverCommand(route)
261 if ref, bound := ctrl.SessionRef(); !bound || ref != held {
262 t.Fatalf("takeover did not attach: %+v bound=%v", ref, bound)
263 }
264
265 exited := make(chan struct{}, 1)
266 m.takeover.SetYieldCallback(func() { exited <- struct{}{} })
267 fake.reclaim.Store(true)
268 m.takeover.Emit(event.Event{Kind: event.Text, Text: "answer"})
269 select {
270 case <-exited:
271 case <-time.After(5 * time.Second):
272 t.Fatal("reclaim did not yield the canonical mirror")
273 }
274 if !m.takeover.Returned() {
275 t.Fatal("canonical mirror not marked returned")
276 }
277 fake.mu.Lock()
278 ends := append([]string(nil), fake.mirrorEnd...)
279 fake.mu.Unlock()
280 if len(ends) != 1 || ends[0] != route {
281 t.Fatalf("mirror-end requests = %v, want one for %q", ends, route)
282 }
283 }
284
285 // TestCanonicalReclaimKeepsTUIAliveAndSwitchesSession proves that reclaiming
286 // one identity releases its writer while leaving the CLI process available to
287 // resume another identity.
288 func TestCanonicalReclaimKeepsTUIAliveAndSwitchesSession(t *testing.T) {
289 route := cliCanonicalRoute("held")
290 fake := newFakeCanonicalServe(t, route)
291 withFakeCanonicalDiscovery(t, fake.base)
292 m, ctrl, service, held := newCanonicalTakeoverTUI(t)
293 if err := ctrl.RecordSessionMessages(t.Context(), "reclaim-test", []provider.Message{{
294 ID: agent.NewMessageID(), Role: provider.RoleUser, Content: "existing session",
295 }}); err != nil {
296 t.Fatal(err)
297 }
298 m.runCanonicalTakeoverCommand(route)
299
300 yielded := make(chan struct{}, 1)
301 m.takeover.SetYieldCallback(func() { yielded <- struct{}{} })
302 fake.reclaim.Store(true)
303 m.takeover.Emit(event.Event{Kind: event.Text, Text: "answer"})
304 select {
305 case <-yielded:
306 case <-time.After(5 * time.Second):
307 t.Fatal("reclaim did not yield the identity")
308 }
309
310 if next, cmd := m.Update(tuiSessionReclaimedMsg{}); cmd != nil {
311 t.Fatalf("reclaim updated TUI with quit command %T", cmd)
312 } else {
313 updated := next.(chatTUI)
314 m = &updated
315 }
316 // The reclaimed conversation stays rendered with a notice instead of a
317 // forced chooser; only the switch/takeover/exit commands are accepted.
318 if !m.sessionReclaimed || m.resumePick != nil {
319 t.Fatalf("reclaim state = %v picker=%v, want live TUI on the reclaimed session", m.sessionReclaimed, m.resumePick != nil)
320 }
321 if reclaimInputAllowed("hello") {
322 t.Fatal("reclaim gate accepted plain text")
323 }
324 if !reclaimInputAllowed("/resume 2") {
325 t.Fatal("reclaim gate rejected /resume")
326 }
327 dir, err := service.SessionDir(t.Context(), held)
328 if err != nil {
329 t.Fatal(err)
330 }
331 if session.ProbeWriterHeld(dir) {
332 t.Fatal("reclaimed identity writer lock is still held by the CLI")
333 }
334
335 // /resume opens the chooser on demand and switching clears the reclaim state.
336 m.runResumeCommand("/resume")
337 if m.resumePick == nil {
338 t.Fatal("/resume after reclaim did not open the session chooser")
339 }
340 entries := m.resumePick.entries
341 for i, entry := range entries {
342 if entry.target.canonical() && entry.target.ref.SessionID == "fresh-cli" {
343 m.resumePick.sel = i
344 if m.resumePick.quick != nil {
345 m.resumePick.quick.selected = i
346 }
347 break
348 }
349 }
350 if m.resumePick.sel < 0 || m.resumePick.sel >= len(entries) || !entries[m.resumePick.sel].target.canonical() || entries[m.resumePick.sel].target.ref.SessionID != "fresh-cli" {
351 t.Fatalf("resume picker did not expose fresh-cli: %+v", entries)
352 }
353 // Confirm through the same key path used by the live picker. Calling
354 // applyResumePick directly would miss routing or quick-picker regressions.
355 next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
356 if cmd != nil {
357 t.Fatalf("resume after reclaim returned unexpected command %T", cmd)
358 }
359 updated := next.(chatTUI)
360 m = &updated
361 if ref, bound := ctrl.SessionRef(); !bound || ref.SessionID != "fresh-cli" {
362 t.Fatalf("controller after reclaim resume = %+v bound=%v, want fresh-cli", ref, bound)
363 }
364 if m.sessionReclaimed || m.takeover.Returned() {
365 t.Fatal("reclaim marker remained after switching to another session")
366 }
367 }
368
369 // TestCanonicalReclaimCanTakeSameSessionAgain covers the complete ownership
370 // round trip: CLI takeover, desktop reclaim, then CLI takeover once more.
371 func TestCanonicalReclaimCanTakeSameSessionAgain(t *testing.T) {
372 route := cliCanonicalRoute("held")
373 fake := newFakeCanonicalServe(t, route)
374 withFakeCanonicalDiscovery(t, fake.base)
375 m, ctrl, _, held := newCanonicalTakeoverTUI(t)
376 m.runCanonicalTakeoverCommand(route)
377
378 yielded := make(chan struct{}, 1)
379 m.takeover.SetYieldCallback(func() { yielded <- struct{}{} })
380 fake.reclaim.Store(true)
381 m.takeover.Emit(event.Event{Kind: event.Text, Text: "answer"})
382 select {
383 case <-yielded:
384 case <-time.After(5 * time.Second):
385 t.Fatal("reclaim did not yield the identity")
386 }
387 if next, cmd := m.Update(tuiSessionReclaimedMsg{}); cmd != nil {
388 t.Fatalf("reclaim updated TUI with quit command %T", cmd)
389 } else {
390 updated := next.(chatTUI)
391 m = &updated
392 }
393 if _, bound := ctrl.SessionRef(); bound {
394 t.Fatal("controller remained bound after desktop reclaim")
395 }
396
397 fake.reclaim.Store(false)
398 // The user-facing path: after a reclaim the notice says "/takeover takes
399 // it back" — the command must resolve the remembered reclaimed target
400 // without a prior /resume (which would re-populate pendingTakeoverPath).
401 m.runTakeoverCommand("/takeover")
402 if ref, bound := ctrl.SessionRef(); !bound || ref != held {
403 t.Fatalf("controller after second takeover = %+v bound=%v, want %+v", ref, bound, held)
404 }
405 if m.sessionReclaimed || m.takeover.Returned() {
406 t.Fatal("second takeover left the CLI in reclaimed mode")
407 }
408 }
409
410 // TestDiscoverCLIServesSkipsDeadPIDs pins the discovery prune: state files
411 // whose recorded process is gone must not surface as takeover candidates —
412 // dialing their stale ports only produces connection-refused noise.
413 func TestDiscoverCLIServesSkipsDeadPIDs(t *testing.T) {
414 home := t.TempDir()
415 stateDir := filepath.Join(home, "remote")
416 if err := os.MkdirAll(stateDir, 0o700); err != nil {
417 t.Fatal(err)
418 }
419 t.Setenv("REASONIX_HOME", home)
420 write := func(slug, addr string, pid int) {
421 t.Helper()
422 state := map[string]any{"pid": pid, "addr": addr, "workspace": "/tmp/" + slug}
423 data, err := json.Marshal(state)
424 if err != nil {
425 t.Fatal(err)
426 }
427 if err := os.WriteFile(filepath.Join(stateDir, "serve-"+slug+".json"), data, 0o600); err != nil {
428 t.Fatal(err)
429 }
430 if err := os.WriteFile(filepath.Join(stateDir, "serve-"+slug+".token"), []byte("tok-"+slug), 0o600); err != nil {
431 t.Fatal(err)
432 }
433 }
434 write("dead-one", "127.0.0.1:44173", 111)
435 write("alive-one", "127.0.0.1:33863", 222)
436
437 previous := cliServeProcessAlive
438 cliServeProcessAlive = func(pid int) bool { return pid == 222 }
439 t.Cleanup(func() { cliServeProcessAlive = previous })
440
441 records := discoverCLIServes()
442 if len(records) != 1 || records[0].base != "http://127.0.0.1:33863" || records[0].token != "tok-alive-one" {
443 t.Fatalf("discovery after prune = %+v, want only the alive record", records)
444 }
445 }
446
447 // TestCanonicalTakeoverDoesNotRetryOnServeVerdict pins the retry rule: a serve
448 // that answers — here the wait-mode "still running" verdict, which already
449 // cost one bounded drain window — is not asked again through a second
450 // discovery pass, so the synchronous worst case is one round, not two.
451 func TestCanonicalTakeoverDoesNotRetryOnServeVerdict(t *testing.T) {
452 var handoffs atomic.Int32
453 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
454 switch r.URL.Path {
455 case "/auth/token":
456 w.WriteHeader(http.StatusNoContent)
457 case "/handoff":
458 handoffs.Add(1)
459 http.Error(w, "session is still running; retry with mode=interrupt", http.StatusConflict)
460 default:
461 w.WriteHeader(http.StatusNotFound)
462 }
463 }))
464 defer srv.Close()
465 var discoveries atomic.Int32
466 previous := discoverCLIServesForTakeover
467 discoverCLIServesForTakeover = func() []cliServeRecord {
468 discoveries.Add(1)
469 return []cliServeRecord{{pid: 1, base: srv.URL, token: "test-token"}}
470 }
471 t.Cleanup(func() { discoverCLIServesForTakeover = previous })
472
473 binding, err := cliTakeoverIdentityHeldSession(cliCanonicalRoute("held"), nil)
474
475 if binding != nil || err == nil || !strings.Contains(err.Error(), "still running") {
476 t.Fatalf("takeover = %+v, %v; want the serve's verdict", binding, err)
477 }
478 if cliServeUnreachable(err) {
479 t.Fatal("an HTTP verdict was classified as a transport failure")
480 }
481 if got := handoffs.Load(); got != 1 {
482 t.Fatalf("handoff requests = %d, want exactly 1 after a verdict", got)
483 }
484 if got := discoveries.Load(); got != 1 {
485 t.Fatalf("discovery passes = %d, want 1 after a verdict", got)
486 }
487 }
488
489 // TestCanonicalTakeoverRediscoversAfterTransportFailure keeps the one
490 // re-discovery pass for the case it exists for: every recorded serve was
491 // unreachable (a desktop reconnect respawned it), and the fresh state file
492 // names the live serve.
493 func TestCanonicalTakeoverRediscoversAfterTransportFailure(t *testing.T) {
494 route := cliCanonicalRoute("held")
495 fake := newFakeCanonicalServe(t, route)
496 var discoveries atomic.Int32
497 previous := discoverCLIServesForTakeover
498 discoverCLIServesForTakeover = func() []cliServeRecord {
499 if discoveries.Add(1) == 1 {
500 // Nothing listens on port 1: the dial fails at the transport.
501 return []cliServeRecord{{pid: 1, base: "http://127.0.0.1:1", token: "stale-token"}}
502 }
503 return []cliServeRecord{{pid: 2, base: fake.base, token: "test-token"}}
504 }
505 t.Cleanup(func() { discoverCLIServesForTakeover = previous })
506
507 binding, err := cliTakeoverIdentityHeldSession(route, nil)
508
509 if err != nil || binding == nil || binding.path != route {
510 t.Fatalf("takeover = %+v, %v; want a grant from the re-discovered serve", binding, err)
511 }
512 if got := discoveries.Load(); got != 2 {
513 t.Fatalf("discovery passes = %d, want 2 (one after the transport failure)", got)
514 }
515 if fake.handoffCount() != 1 {
516 t.Fatalf("handoff requests to the live serve = %d, want 1", fake.handoffCount())
517 }
518 }
519
520 // TestCanonicalTakeoverOfFreeSessionResumes covers the promise the reclaim
521 // notice makes after the desktop closed the session it took back: no runtime
522 // holds the identity any more, so there is nothing to hand over and
523 // /takeover resumes it instead of failing with "no resident serve holds this
524 // session". Both shapes of "closed" are covered: the serve exited, and a
525 // resident serve that no longer holds the session and refuses.
526 func TestCanonicalTakeoverOfFreeSessionResumes(t *testing.T) {
527 t.Run("the serve exited", func(t *testing.T) {
528 previous := discoverCLIServesForTakeover
529 discoverCLIServesForTakeover = func() []cliServeRecord { return nil }
530 t.Cleanup(func() { discoverCLIServesForTakeover = previous })
531 m, ctrl, _, held := newCanonicalTakeoverTUI(t)
532
533 m.runCanonicalTakeoverCommand(cliCanonicalRoute("held"))
534
535 if ref, bound := ctrl.SessionRef(); !bound || ref != held {
536 t.Fatalf("controller after /takeover of a free session = %+v bound=%v, want %+v", ref, bound, held)
537 }
538 if binding, _, _, _ := m.takeover.snapshot(); binding != nil {
539 t.Fatalf("resuming a free session activated a mirror: %+v", binding)
540 }
541 })
542 t.Run("a resident serve no longer holds it", func(t *testing.T) {
543 route := cliCanonicalRoute("held")
544 fake := newFakeCanonicalServe(t, route)
545 withFakeCanonicalDiscovery(t, fake.base)
546 m, ctrl, _, held := newCanonicalTakeoverTUI(t)
547 m.runCanonicalTakeoverCommand(route)
548 yielded := make(chan struct{}, 1)
549 m.takeover.SetYieldCallback(func() { yielded <- struct{}{} })
550 fake.reclaim.Store(true)
551 m.takeover.Emit(event.Event{Kind: event.Text, Text: "answer"})
552 select {
553 case <-yielded:
554 case <-time.After(5 * time.Second):
555 t.Fatal("reclaim did not yield the identity")
556 }
557 next, _ := m.Update(tuiSessionReclaimedMsg{})
558 updated := next.(chatTUI)
559 m = &updated
560 // The desktop closed the tab: the serve stays resident but refuses,
561 // and nobody holds the writer.
562 refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
563 if r.URL.Path == "/auth/token" {
564 w.WriteHeader(http.StatusNoContent)
565 return
566 }
567 http.Error(w, "session is not held by this serve process", http.StatusConflict)
568 }))
569 defer refusing.Close()
570 withFakeCanonicalDiscovery(t, refusing.URL)
571
572 m.runTakeoverCommand("/takeover")
573
574 if ref, bound := ctrl.SessionRef(); !bound || ref != held {
575 t.Fatalf("controller after /takeover = %+v bound=%v, want %+v resumed", ref, bound, held)
576 }
577 if m.sessionReclaimed || m.takeover.Returned() {
578 t.Fatal("/takeover of the freed session left the CLI in reclaimed mode")
579 }
580 if binding, _, _, _ := m.takeover.snapshot(); binding != nil {
581 t.Fatalf("resuming a free session activated a mirror: %+v", binding)
582 }
583 })
584 }
585
586 // TestCanonicalTakeoverOfActiveSessionIsRejected keeps /takeover from
587 // "resuming" the identity this controller already writes.
588 func TestCanonicalTakeoverOfActiveSessionIsRejected(t *testing.T) {
589 previous := discoverCLIServesForTakeover
590 discoverCLIServesForTakeover = func() []cliServeRecord { return nil }
591 t.Cleanup(func() { discoverCLIServesForTakeover = previous })
592 m, ctrl, _, _ := newCanonicalTakeoverTUI(t)
593
594 m.runCanonicalTakeoverCommand(cliCanonicalRoute("fresh-cli"))
595
596 if ref, bound := ctrl.SessionRef(); !bound || ref.SessionID != "fresh-cli" {
597 t.Fatalf("controller = %+v bound=%v, want the active session untouched", ref, bound)
598 }
599 out := strings.Join(m.transcript, "\n")
600 if !strings.Contains(out, i18n.M.ResumeAlreadyActive) {
601 t.Fatalf("transcript missing the already-active notice:\n%s", out)
602 }
603 if strings.Contains(out, i18n.M.ResumedTitle) {
604 t.Fatalf("/takeover of the active session replayed the transcript:\n%s", out)
605 }
606 }
607
607 lines GO