返回 DeepSeek-Reasonix
session_reclaim_events_test.go
根目录 / internal / control / session_reclaim_events_test.go
1 package control
2
3 import (
4 "context"
5 "path/filepath"
6 "testing"
7
8 "reasonix/internal/agent"
9 "reasonix/internal/event"
10 "reasonix/internal/provider"
11 "reasonix/internal/session"
12 "reasonix/internal/tool"
13 )
14
15 // A reclaim releases the CLI's writer and closes the runtime's store; the
16 // TUI stays alive rendering the conversation. A second /takeover re-opens the
17 // same identity, and the controller's cached turn-event store must follow the
18 // new runtime instance — the identity path alone matches, so the stale cache
19 // would keep answering turn admission from the closed recovery database
20 // ("database not open").
21 func TestOpenSessionReopensRuntimeClosedByReclaim(t *testing.T) {
22 service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions-v4")))
23 if err != nil {
24 t.Fatal(err)
25 }
26 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "ping-pong"})
27 if err != nil {
28 t.Fatal(err)
29 }
30 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
31 controller := newOwnedTestController(t, Options{
32 Executor: exec, Sink: event.Discard, SessionService: service,
33 SessionRuntime: runtime, ExclusiveSession: true,
34 })
35 // The turn-event store caches the live instance the binding resolved.
36 controller.rebindTurnEvents("")
37 controller.turnEvents.mu.RLock()
38 cached := controller.turnEvents.v3
39 controller.turnEvents.mu.RUnlock()
40 if cached != runtime.Session() {
41 t.Fatal("fixture did not seed the turn-event store cache")
42 }
43
44 // The reclaim release: drop every binding, then close the service runtime.
45 if err := controller.ReleaseSessionRuntimeBinding(); err != nil {
46 t.Fatal(err)
47 }
48 if err := service.Close(t.Context(), runtime.Ref()); err != nil {
49 t.Fatal(err)
50 }
51
52 published, err := controller.OpenSession(t.Context(), runtime.Ref())
53 if err != nil {
54 t.Fatalf("re-takeover after reclaim failed to re-open the session: %v", err)
55 }
56 if published != runtime.Ref() {
57 t.Fatalf("re-opened ref = %v, want %v", published, runtime.Ref())
58 }
59 _, reopened, _ := controller.v3Binding()
60 if reopened == nil || reopened == runtime {
61 t.Fatal("OpenSession kept the closed runtime instead of re-opening")
62 }
63 // The path that surfaced the bug: the cached store must now be the fresh
64 // runtime's session, and a runtime-scoped operation lookup must answer
65 // from a live recovery store instead of the closed database.
66 controller.turnEvents.mu.RLock()
67 recached := controller.turnEvents.v3
68 controller.turnEvents.mu.RUnlock()
69 if recached != reopened.Session() {
70 t.Fatal("turn-event store cache still serves the runtime closed by reclaim")
71 }
72 if _, err := recached.AppendBatch(t.Context(), "retakeover-probe", []session.Event{{Kind: "diagnostic", Optional: true}}); err != nil {
73 t.Fatalf("re-opened runtime store is unusable: %v", err)
74 }
75 }
76
77 // A handoff hands the identity to another runtime without allocating a
78 // replacement: the controller flushes, drops its binding and empties the
79 // in-memory transcript, so it is back in the never-bound exclusive state. From
80 // there NewSession must allocate on demand instead of failing on the missing
81 // runtime, and a Snapshot must be a no-op rather than an "unbound runtime"
82 // error.
83 func TestReleaseSessionForHandoffLeavesControllerAllocatable(t *testing.T) {
84 service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions-v4")))
85 if err != nil {
86 t.Fatal(err)
87 }
88 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
89 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
90 controller := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: service, ExclusiveSession: true})
91 handedOff, err := controller.BindFreshSession(t.Context(), "handed-off")
92 if err != nil {
93 t.Fatal(err)
94 }
95 controller.AdoptHistory([]provider.Message{{ID: "u1", Role: provider.RoleUser, Content: "keep me durable"}}, "")
96 if msgs := controller.History(); len(msgs) == 0 {
97 t.Fatal("fixture did not seed the bound session")
98 }
99
100 if err := controller.ReleaseSessionForHandoff(); err != nil {
101 t.Fatalf("release for handoff: %v", err)
102 }
103 if _, bound := controller.SessionRef(); bound {
104 t.Fatal("controller still bound after release")
105 }
106 for _, msg := range controller.History() {
107 if msg.Role != provider.RoleSystem {
108 t.Fatalf("released controller still carries the handed-off conversation: %+v", msg)
109 }
110 }
111 if err := controller.Snapshot(); err != nil {
112 t.Fatalf("snapshot of a released controller must be a no-op, got %v", err)
113 }
114 // The host closes the runtime; the flushed turn must already be durable.
115 if err := service.Close(t.Context(), handedOff); err != nil {
116 t.Fatal(err)
117 }
118 msgs, err := service.Query().History(t.Context(), handedOff)
119 if err != nil {
120 t.Fatal(err)
121 }
122 if len(msgs) == 0 || msgs[len(msgs)-1].Content != "keep me durable" {
123 t.Fatalf("handed-off session lost its flushed tail: %+v", msgs)
124 }
125
126 if err := controller.NewSession(); err != nil {
127 t.Fatalf("NewSession on a released controller: %v", err)
128 }
129 fresh, bound := controller.SessionRef()
130 if !bound || fresh == handedOff {
131 t.Fatalf("NewSession bound %+v (bound %v), want a fresh identity", fresh, bound)
132 }
133 }
134
134 lines GO