返回 DeepSeek-Reasonix
remote_tab_session_transition_test.go
根目录 / desktop / remote_tab_session_transition_test.go
1 package main
2
3 import (
4 "reflect"
5 "strings"
6 "testing"
7 "time"
8 )
9
10 func TestRemoteResumeBusyKeepsCurrentSessionReady(t *testing.T) {
11 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "saved", Path: "/saved.jsonl", Title: "Saved"}})
12 kernel := &fakeRemoteKernel{
13 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
14 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
15 }
16 seedBridgeTestHost(t, "box")
17 a := &App{remoteRuntime: kernel}
18 cleanupRemoteTabPumps(t, a)
19 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
20 fs.mu.Lock()
21 fs.failEnter = "cannot resume while a turn is running"
22 fs.mu.Unlock()
23 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "saved"}); err != nil {
24 t.Fatal(err)
25 }
26 waitForRemoteTabError(t, a, meta.ID, "Finish the current turn")
27 a.remoteTabMu.Lock()
28 state, message := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].err
29 a.remoteTabMu.Unlock()
30 if state != "ready" || !strings.Contains(message, "Finish the current turn") {
31 t.Fatalf("busy resume state/error = %q/%q, want ready non-terminal notice", state, message)
32 }
33 }
34
35 func TestRemoteResumeRejectedKeepsCurrentSessionReady(t *testing.T) {
36 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "saved", Path: "/saved.jsonl", Title: "Saved"}})
37 kernel := &fakeRemoteKernel{
38 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
39 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
40 }
41 seedBridgeTestHost(t, "box")
42 a := &App{remoteRuntime: kernel}
43 cleanupRemoteTabPumps(t, a)
44 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
45 fs.mu.Lock()
46 fs.failEnter = "session is already leased by another process"
47 fs.mu.Unlock()
48 a.resumeRemoteTabSession(meta.ID, "saved")
49 a.remoteTabMu.Lock()
50 state, message := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].err
51 a.remoteTabMu.Unlock()
52 if state != "ready" || !strings.Contains(message, "already leased") {
53 t.Fatalf("rejected resume state/error = %q/%q, want ready action error", state, message)
54 }
55 }
56
57 func TestRemoteResumeRejectedRestoresForegroundRoute(t *testing.T) {
58 const oldPath = "/old.jsonl"
59 const targetPath = "/target.jsonl"
60 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
61 {Name: "old", Path: oldPath, Current: true},
62 {Name: "target", Path: targetPath},
63 })
64 kernel := &fakeRemoteKernel{
65 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
66 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
67 }
68 seedBridgeTestHost(t, "box")
69 a := &App{remoteRuntime: kernel}
70 cleanupRemoteTabPumps(t, a)
71 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{})
72 fs.mu.Lock()
73 fs.failEnter = "session is already leased by another process"
74 fs.mu.Unlock()
75 a.resumeRemoteTabSessionPath(meta.ID, "target", targetPath, "Target")
76 a.remoteTabMu.Lock()
77 got := a.remoteTabs[meta.ID].routing.currentPath
78 a.remoteTabMu.Unlock()
79 if got != oldPath {
80 t.Fatalf("foreground route after rejected resume = %q, want %q", got, oldPath)
81 }
82 }
83
84 func TestRemoteResumeBuffersTargetFramesUntilPostCommit(t *testing.T) {
85 const oldPath = "/old.jsonl"
86 const targetPath = "/target.jsonl"
87 feed := make(chan string, 4)
88 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
89 {Name: "old", Path: oldPath, Current: true},
90 {Name: "target", Path: targetPath, Running: true},
91 })
92 fs.mu.Lock()
93 fs.eventFrames = []string{`{"kind":"ready","sessionPath":"/old.jsonl"}`}
94 fs.eventFeed = feed
95 fs.resumeStarted = make(chan string, 1)
96 fs.resumeRelease = make(chan struct{})
97 started, release := fs.resumeStarted, fs.resumeRelease
98 fs.mu.Unlock()
99 t.Cleanup(func() {
100 select {
101 case <-release:
102 default:
103 close(release)
104 }
105 })
106 kernel := &fakeRemoteKernel{
107 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
108 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
109 }
110 seedBridgeTestHost(t, "box")
111 log := &eventLog{}
112 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
113 cleanupRemoteTabPumps(t, a)
114 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{})
115 eventPrefix := "remote-tab:" + meta.ID + ":event"
116 readyPrefix := "remote-tab:" + meta.ID + ":state"
117 waitForRemoteEventCount(t, log, readyPrefix, 2)
118 eventsBefore, readyBefore := log.count(eventPrefix), log.count(readyPrefix)
119 done := make(chan struct{})
120 go func() {
121 a.resumeRemoteTabSessionPath(meta.ID, "target", targetPath, "Target")
122 close(done)
123 }()
124 select {
125 case <-started:
126 case <-time.After(time.Second):
127 t.Fatal("resume request did not start")
128 }
129 // Until /resume commits, Serve's authoritative status is still the old
130 // foreground. A poll from the runtime watchdog must not roll the provisional
131 // target route back and create a second target ready barrier.
132 _, _ = a.RemoteTabStatus(meta.ID)
133 a.remoteTabMu.Lock()
134 provisionalPath := a.remoteTabs[meta.ID].routing.currentPath
135 rehydratingPath := a.remoteTabs[meta.ID].routing.rehydratingPath
136 a.remoteTabMu.Unlock()
137 if provisionalPath != targetPath || rehydratingPath != targetPath {
138 t.Fatalf("old status rolled back provisional route: current/rehydrating = %q/%q", provisionalPath, rehydratingPath)
139 }
140 feed <- `{"kind":"approval_request","approval":{"id":"target-approval"},"sessionPath":"/target.jsonl","sessionCurrent":true}`
141 feed <- `{"kind":"text","text":"first retained delta","sessionPath":"/target.jsonl","sessionCurrent":true}`
142 feed <- `{"kind":"notice","text":"second retained notice","sessionPath":"/target.jsonl","sessionCurrent":true}`
143 deadline := time.Now().Add(time.Second)
144 for {
145 a.remoteTabMu.Lock()
146 pending := len(a.remoteTabs[meta.ID].pendingEvents)
147 buffered := len(a.remoteTabs[meta.ID].routing.rehydratingFrames)
148 a.remoteTabMu.Unlock()
149 if pending == 1 && buffered == 3 {
150 break
151 }
152 select {
153 case <-done:
154 t.Fatal("resume returned before the test released its response")
155 default:
156 }
157 if time.Now().After(deadline) {
158 t.Fatalf("target frames were not retained while /resume was pending: pending=%d buffered=%d log=%v", pending, buffered, log.recorded())
159 }
160 time.Sleep(time.Millisecond)
161 }
162 if got := log.count(eventPrefix); got != eventsBefore {
163 t.Fatalf("provisional target frame reached the old transcript: events %d -> %d, log=%v", eventsBefore, got, log.recorded())
164 }
165 close(release)
166 select {
167 case <-done:
168 case <-time.After(time.Second):
169 t.Fatal("resume did not finish")
170 }
171 if got := log.count(readyPrefix); got != readyBefore+1 {
172 t.Fatalf("committed resume emitted %d ready barriers, want %d: %v", got, readyBefore+1, log.recorded())
173 }
174 events := log.recorded()
175 readyIndex, approvalIndex, firstIndex, secondIndex := -1, -1, -1, -1
176 for i, got := range events {
177 if strings.HasPrefix(got, readyPrefix+" ") {
178 readyIndex = i
179 }
180 if strings.Contains(got, `"id":"target-approval"`) {
181 approvalIndex = i
182 }
183 if strings.Contains(got, `"text":"first retained delta"`) {
184 firstIndex = i
185 }
186 if strings.Contains(got, `"text":"second retained notice"`) {
187 secondIndex = i
188 }
189 }
190 if readyIndex < 0 || approvalIndex <= readyIndex || firstIndex <= approvalIndex || secondIndex <= firstIndex {
191 t.Fatalf("buffered target frames were not replayed in order after ready: %v", events)
192 }
193 a.remoteTabMu.Lock()
194 pending := len(a.remoteTabs[meta.ID].pendingEvents)
195 rehydrating := a.remoteTabs[meta.ID].routing.rehydratingPath
196 a.remoteTabMu.Unlock()
197 if pending != 1 || rehydrating != "" {
198 t.Fatalf("committed target pending/rehydrating = %d/%q, want 1/empty", pending, rehydrating)
199 }
200 }
201
202 func TestRemoteNewBusyKeepsCurrentSessionReady(t *testing.T) {
203 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "saved", Path: "/saved.jsonl", Title: "Saved", Current: true}})
204 kernel := &fakeRemoteKernel{
205 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
206 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
207 }
208 seedBridgeTestHost(t, "box")
209 a := &App{remoteRuntime: kernel}
210 cleanupRemoteTabPumps(t, a)
211 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "saved"})
212 a.remoteTabMu.Lock()
213 previousTitle := a.remoteTabs[meta.ID].topicTitle
214 previousSession := a.remoteTabs[meta.ID].session
215 previousRoute := a.remoteTabs[meta.ID].routing.currentPath
216 previousRuntime := a.remoteTabs[meta.ID].runtime
217 a.remoteTabMu.Unlock()
218 fs.mu.Lock()
219 fs.failEnter = "cannot start a new session while a turn is running"
220 fs.mu.Unlock()
221 if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true}); err == nil || !strings.Contains(err.Error(), "while a turn is running") {
222 t.Fatalf("busy new-session error = %v", err)
223 }
224 a.remoteTabMu.Lock()
225 tab := a.remoteTabs[meta.ID]
226 state, message, title, client := tab.state, tab.err, tab.topicTitle, tab.client
227 session, route, runtime := tab.session, tab.routing.currentPath, tab.runtime
228 a.remoteTabMu.Unlock()
229 if state != "ready" || message != "" || title != previousTitle || client == nil {
230 t.Fatalf("busy new-session state/error/title/client = %q/%q/%q/%v, want ready current attachment", state, message, title, client)
231 }
232 if session != previousSession || route != previousRoute || !reflect.DeepEqual(runtime, previousRuntime) {
233 t.Fatalf("busy new-session changed current identity/runtime: session=%+v route=%q runtime=%+v", session, route, runtime)
234 }
235 }
236
237 func TestRemoteResumeLeaseConflictFailsAttach(t *testing.T) {
238 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "saved", Path: "/saved.jsonl", Title: "Saved"}})
239 fs.mu.Lock()
240 fs.failEnter = "this session is in use by another Reasonix window or process"
241 fs.mu.Unlock()
242 kernel := &fakeRemoteKernel{
243 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
244 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
245 }
246 seedBridgeTestHost(t, "box")
247 a := &App{remoteRuntime: kernel}
248 cleanupRemoteTabPumps(t, a)
249 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "saved"})
250 if err != nil {
251 t.Fatal(err)
252 }
253 waitForTabState(t, a, meta.ID, "error")
254 a.remoteTabMu.Lock()
255 state, message, client := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].err, a.remoteTabs[meta.ID].client
256 a.remoteTabMu.Unlock()
257 if state != "error" || !strings.Contains(message, "session is in use") || client != nil {
258 t.Fatalf("lease-conflict attach state/error/client = %q/%q/%v", state, message, client)
259 }
260 }
261
262 func TestRemoteResumeListFailureKeepsCurrentAttachmentReady(t *testing.T) {
263 fs := newFakeServe(t, "s3cret", []serveSessionEntry{{Name: "saved", Path: "/saved.jsonl", Title: "Saved"}})
264 kernel := &fakeRemoteKernel{
265 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
266 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, ensureToken: "s3cret",
267 }
268 seedBridgeTestHost(t, "box")
269 a := &App{remoteRuntime: kernel}
270 cleanupRemoteTabPumps(t, a)
271 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
272 fs.mu.Lock()
273 fs.failSessions = true
274 fs.mu.Unlock()
275 a.resumeRemoteTabSession(meta.ID, "saved")
276 a.remoteTabMu.Lock()
277 state, message := a.remoteTabs[meta.ID].state, a.remoteTabs[meta.ID].err
278 a.remoteTabMu.Unlock()
279 if state != "ready" || !strings.Contains(message, "Could not open remote session") {
280 t.Fatalf("list failure state/error = %q/%q, want ready non-terminal notice", state, message)
281 }
282 }
283
283 lines GO