返回 DeepSeek-Reasonix
remote_tab_test.go
根目录 / desktop / remote_tab_test.go
1 package main
2
3 import (
4 "net/http"
5 "strings"
6 "testing"
7 "time"
8
9 "reasonix/internal/config"
10 )
11
12 func TestRegisterRemoteTabOpenReviveCarriesSelectedTitle(t *testing.T) {
13 ref := RemoteTabRef{HostID: "box", Workspace: "~/app"}
14 existing := &remoteTab{
15 id: "remote-1", ref: ref, state: "disconnected", topicTitle: "Old title",
16 session: remoteTabSessionState{name: "old", path: "/sessions/old.jsonl"},
17 routing: remoteTabSessionRouting{currentPath: "/sessions/old.jsonl", running: map[string]bool{}},
18 }
19 a := &App{remoteTabs: map[string]*remoteTab{existing.id: existing}}
20 registration := a.registerRemoteTabOpen(&remoteTab{id: "unused", ref: ref}, "Box", RemoteTabOpenOptions{
21 SessionName: "selected", SessionPath: "/sessions/selected.jsonl", SessionTitle: "Selected title",
22 })
23 if registration.reuseID != existing.id || !registration.revive {
24 t.Fatalf("registration = %+v, want revived %q", registration, existing.id)
25 }
26 if existing.topicTitle != "Old title" || existing.routing.currentPath != "/sessions/old.jsonl" {
27 t.Fatalf("registration changed identity before visibility commit: title=%q path=%q", existing.topicTitle, existing.routing.currentPath)
28 }
29 if !a.commitRemoteTabOpenRegistration(&registration, "Box", RemoteTabOpenOptions{
30 SessionName: "selected", SessionPath: "/sessions/selected.jsonl", SessionTitle: "Selected title",
31 }) {
32 t.Fatal("commitRemoteTabOpenRegistration rejected the reused shell")
33 }
34 if existing.topicTitle != "Selected title" {
35 t.Fatalf("revived title = %q, want selected title", existing.topicTitle)
36 }
37 }
38
39 // TestRemoteTabBridgeEntersNewSessionAndStreams pins the happy path: open →
40 // handshake → pump subscribed → POST /new → ready, with frames forwarded on
41 // the tab's event channel and the session cookie riding the jar.
42 func TestRemoteTabBridgeEntersNewSessionAndStreams(t *testing.T) {
43 fs := newFakeServe(t, "s3cret", nil)
44 fs.newSessionPath = "/sessions/fresh.jsonl"
45 kernel := &fakeRemoteKernel{
46 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
47 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
48 ensureToken: "s3cret",
49 }
50 seedBridgeTestHost(t, "box")
51 log := &eventLog{}
52 a := &App{remoteRuntime: kernel, remoteEventHook: log.add}
53 cleanupRemoteTabPumps(t, a)
54 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
55 if err != nil {
56 t.Fatal(err)
57 }
58 waitForTabState(t, a, meta.ID, "ready")
59 newCalled, _, cookieOnNew := fs.snapshot()
60 if newCalled != 1 {
61 t.Fatalf("POST /new called %d times, want 1", newCalled)
62 }
63 if !cookieOnNew {
64 t.Fatal("POST /new carried no session cookie; handshake did not populate the jar")
65 }
66 if got := log.count("remote-tab:" + meta.ID + ":event"); got < 2 {
67 t.Fatalf("pump forwarded %d frames, want ≥2 (events: %v)", got, log.events)
68 }
69 fs.mu.Lock()
70 eventsQuery := fs.eventsQuery
71 fs.mu.Unlock()
72 if eventsQuery != "all=1" {
73 t.Fatalf("event stream query = %q, want all=1", eventsQuery)
74 }
75 a.remoteTabMu.Lock()
76 currentPath := a.remoteTabs[meta.ID].routing.currentPath
77 a.remoteTabMu.Unlock()
78 if currentPath != "/sessions/fresh.jsonl" {
79 t.Fatalf("current session path = %q, want response header path", currentPath)
80 }
81 // State becomes observable immediately before its event is emitted. Wait for
82 // the event too so a slow runner cannot sample that intentional handoff gap.
83 waitForRemoteEventCount(t, log, "remote-tab:"+meta.ID+":state", 2)
84
85 // Cancelling the pump (close/reconnect) must exit silently: no error
86 // state is emitted for a deliberate stop.
87 a.remoteTabMu.Lock()
88 cancel := a.remoteTabs[meta.ID].cancel
89 a.remoteTabMu.Unlock()
90 if cancel != nil {
91 cancel()
92 }
93 time.Sleep(100 * time.Millisecond)
94 a.remoteTabMu.Lock()
95 state := a.remoteTabs[meta.ID].state
96 a.remoteTabMu.Unlock()
97 if state != "ready" {
98 t.Fatalf("state after pump cancel = %q, want ready (silent exit)", state)
99 }
100 }
101
102 func TestConcurrentRemoteProjectOpenReusesOneTab(t *testing.T) {
103 fs := newFakeServe(t, "s3cret", nil)
104 kernel := &fakeRemoteKernel{
105 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
106 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
107 ensureToken: "s3cret",
108 }
109 seedBridgeTestHost(t, "box")
110 a := &App{remoteRuntime: kernel}
111 cleanupRemoteTabPumps(t, a)
112 start := make(chan struct{})
113 results := make(chan TabMeta, 2)
114 errs := make(chan error, 2)
115 for range 2 {
116 go func() {
117 <-start
118 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
119 results <- meta
120 errs <- err
121 }()
122 }
123 close(start)
124 first, second := <-results, <-results
125 if err := <-errs; err != nil {
126 t.Fatal(err)
127 }
128 if err := <-errs; err != nil {
129 t.Fatal(err)
130 }
131 if first.ID == "" || first.ID != second.ID {
132 t.Fatalf("concurrent opens returned %q and %q", first.ID, second.ID)
133 }
134 a.remoteTabMu.Lock()
135 count := len(a.remoteTabs)
136 a.remoteTabMu.Unlock()
137 if count != 1 {
138 t.Fatalf("remote tab count = %d, want one", count)
139 }
140 }
141
142 // TestRemoteTabBridgeToleratesTrailingSlashBase pins the production LocalURL
143 // shape: EnsureServer reports "http://127.0.0.1:port/" with a trailing slash.
144 // Naive base+"/auth/token" concatenation used to hit "//auth/token", which the
145 // serve auth gate rejects with 401 before routing the endpoint — the handshake
146 // died on every wizard-completed connect.
147 func TestRemoteTabBridgeToleratesTrailingSlashBase(t *testing.T) {
148 fs := newFakeServe(t, "s3cret", nil)
149 kernel := &fakeRemoteKernel{
150 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
151 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL + "/"},
152 ensureToken: "s3cret",
153 }
154 seedBridgeTestHost(t, "box")
155 a := &App{remoteRuntime: kernel}
156 cleanupRemoteTabPumps(t, a)
157 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
158 if err != nil {
159 t.Fatal(err)
160 }
161 waitForTabState(t, a, meta.ID, "ready")
162 newCalled, _, cookieOnNew := fs.snapshot()
163 if newCalled != 1 || !cookieOnNew {
164 t.Fatalf("handshake with trailing-slash base failed: /new=%d cookie=%v", newCalled, cookieOnNew)
165 }
166 }
167
168 func TestRemoteTabBusyAttachKeepsCurrentSessionMetadata(t *testing.T) {
169 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
170 {Name: "current", Path: "/sessions/current.jsonl", Title: "Current work", Current: true},
171 })
172 fs.mu.Lock()
173 fs.failEnter = "cannot start a new session while a turn is running"
174 fs.mu.Unlock()
175 kernel := &fakeRemoteKernel{
176 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
177 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
178 ensureToken: "s3cret",
179 }
180 seedBridgeTestHost(t, "box")
181 a := &App{remoteRuntime: kernel}
182 cleanupRemoteTabPumps(t, a)
183 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
184 a.remoteTabMu.Lock()
185 tab := a.remoteTabs[meta.ID]
186 reset, title := tab.session.reset, tab.topicTitle
187 a.remoteTabMu.Unlock()
188 if reset {
189 t.Fatal("a refused /new must not mark the current session as a blank reset")
190 }
191 if title == a.localizedDefaultTopicTitle() {
192 t.Fatalf("a refused /new replaced the current title with %q", title)
193 }
194 if err := a.SubmitRemoteTab(meta.ID, "continue"); err != nil {
195 t.Fatalf("busy attach did not keep the current session usable: %v", err)
196 }
197 }
198
199 // TestRemoteTabBridgeHandshakeFailureSurfacesError pins that a rejected
200 // token lands the tab in error instead of a phantom ready shell.
201 func TestRemoteTabBridgeHandshakeFailureSurfacesError(t *testing.T) {
202 fs := newFakeServe(t, "s3cret", nil)
203 kernel := &fakeRemoteKernel{
204 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
205 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
206 ensureToken: "wrong-token",
207 }
208 seedBridgeTestHost(t, "box")
209 a := &App{remoteRuntime: kernel}
210 cleanupRemoteTabPumps(t, a)
211
212 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
213 if err != nil {
214 t.Fatal(err)
215 }
216 waitForTabState(t, a, meta.ID, "error")
217 a.remoteTabMu.Lock()
218 tabErr := a.remoteTabs[meta.ID].err
219 a.remoteTabMu.Unlock()
220 if !strings.Contains(tabErr, "401") {
221 t.Fatalf("tab error = %q, want handshake 401", tabErr)
222 }
223 if _, _, cookieOnNew := fs.snapshot(); cookieOnNew {
224 t.Fatal("POST /new must not be reached after a failed handshake")
225 }
226 }
227
228 // TestRemoteTabBridgeResumeResolvesSessionPath pins the name→path
229 // resolution: a SessionName open reads GET /sessions and POSTs /resume with
230 // the entry's path, never the bare name.
231 func TestRemoteTabBridgeResumeResolvesSessionPath(t *testing.T) {
232 fs := newFakeServe(t, "s3cret", []serveSessionEntry{
233 {Name: "s1", Path: "/remote/sessions/s1.jsonl", Title: "First", Turns: 2},
234 })
235 kernel := &fakeRemoteKernel{
236 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
237 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
238 ensureToken: "s3cret",
239 }
240 seedBridgeTestHost(t, "box")
241 a := &App{remoteRuntime: kernel}
242 cleanupRemoteTabPumps(t, a)
243 meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{SessionName: "s1"})
244 if err != nil {
245 t.Fatal(err)
246 }
247 waitForTabState(t, a, meta.ID, "ready")
248
249 newCalled, resumePath, _ := fs.snapshot()
250 if newCalled != 0 {
251 t.Fatalf("POST /new called %d times, want 0 for a resume open", newCalled)
252 }
253 if resumePath != "/remote/sessions/s1.jsonl" {
254 t.Fatalf("POST /resume path = %q, want the /sessions entry path", resumePath)
255 }
256 }
257
258 // TestRemoteTabCommandRejectsUnknownOrUnreadyTab: an unknown tabID and a
259 // tab that has not finished bootstrap are errors, never silent no-ops.
260 func TestRemoteTabCommandRejectsUnknownOrUnreadyTab(t *testing.T) {
261 a := &App{}
262 if err := a.SubmitRemoteTab("missing", "hi"); err == nil || !strings.Contains(err.Error(), "not connected") {
263 t.Fatalf("unknown tab err = %v, want not connected", err)
264 }
265 a.remoteTabMu.Lock()
266 a.remoteTabs = map[string]*remoteTab{"booting": {id: "booting", state: "connecting"}}
267 a.remoteTabMu.Unlock()
268 if err := a.SubmitRemoteTab("booting", "hi"); err == nil || !strings.Contains(err.Error(), "not connected") {
269 t.Fatalf("unready tab err = %v, want not connected", err)
270 }
271 a.remoteTabMu.Lock()
272 a.remoteTabs["switching"] = &remoteTab{
273 id: "switching", state: "ready", client: &http.Client{},
274 routing: remoteTabSessionRouting{currentPath: "session-id:old", rehydratingPath: "session-id:new"},
275 }
276 a.remoteTabMu.Unlock()
277 if err := a.SubmitRemoteTab("switching", "hi"); err == nil || !strings.Contains(err.Error(), "switching sessions") {
278 t.Fatalf("session-switching tab err = %v, want switching-session guard", err)
279 }
280 }
281
282 // TestModelsForTabRemoteUsesDesktopCatalog: a remote tab's model switcher
283 // lists the desktop provider catalog. Current is the desktop-owned tab model,
284 // not the remote serve GET /models payload.
285 func TestModelsForTabRemoteUsesDesktopCatalog(t *testing.T) {
286 isolateDesktopUserDirs(t)
287 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
288 cfg := config.Default()
289 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
290 cfg.Desktop.ProviderAccess = []string{"deepseek"}
291 cfg.Providers = append(cfg.Providers, config.ProviderEntry{
292 Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
293 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
294 })
295 if err := cfg.UpsertRemoteHost(config.RemoteHostEntry{Name: "box", Host: "127.0.0.1", Port: 22, User: "dev", CredentialMode: "local-proxy"}); err != nil {
296 t.Fatal(err)
297 }
298 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
299 t.Fatalf("save config: %v", err)
300 }
301
302 fs := newFakeServe(t, "s3cret", nil)
303 kernel := &fakeRemoteKernel{
304 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
305 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
306 ensureToken: "s3cret",
307 }
308 a := &App{remoteRuntime: kernel}
309 cleanupRemoteTabPumps(t, a)
310 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
311
312 got := a.ModelsForTab(meta.ID)
313 refs := map[string]bool{}
314 current := ""
315 for _, m := range got {
316 refs[m.Ref] = true
317 if m.Current {
318 current = m.Ref
319 }
320 }
321 if !refs["deepseek/deepseek-v4-flash"] || !refs["deepseek/deepseek-v4-pro"] {
322 t.Fatalf("ModelsForTab(%s) = %+v, want desktop deepseek catalog", meta.ID, got)
323 }
324 if refs["remote/chat"] {
325 t.Fatalf("ModelsForTab leaked the serve catalog: %+v", got)
326 }
327 if current != "deepseek/deepseek-v4-flash" {
328 t.Fatalf("current = %q, want desktop default_model", current)
329 }
330 }
331
332 // TestSetModelForTabRemoteOwnsModelOnDesktop: picking a model on a remote tab
333 // delegates the failure-atomic switch to the kernel before committing desktop
334 // tab metadata; the binding itself must not issue a second Serve request.
335 func TestSetModelForTabRemoteOwnsModelOnDesktop(t *testing.T) {
336 isolateDesktopUserDirs(t)
337 setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
338 cfg := config.Default()
339 cfg.DefaultModel = "deepseek/deepseek-v4-flash"
340 cfg.Desktop.ProviderAccess = []string{"deepseek"}
341 cfg.Providers = append(cfg.Providers, config.ProviderEntry{
342 Name: "deepseek", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic",
343 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"}, Default: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY",
344 })
345 if err := cfg.UpsertRemoteHost(config.RemoteHostEntry{Name: "box", Host: "127.0.0.1", Port: 22, User: "dev", CredentialMode: "local-proxy"}); err != nil {
346 t.Fatal(err)
347 }
348 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
349 t.Fatalf("save config: %v", err)
350 }
351
352 fs := newFakeServe(t, "s3cret", nil)
353 fs.newSessionPath = "/remote/sessions/model-switch.jsonl"
354 kernel := &fakeRemoteKernel{
355 statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
356 ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
357 ensureToken: "s3cret",
358 }
359 a := &App{remoteRuntime: kernel}
360 cleanupRemoteTabPumps(t, a)
361 meta := openReadyRemoteTab(t, a, RemoteTabOpenOptions{NewSession: true})
362
363 if err := a.SetModelForTab(meta.ID, "deepseek/deepseek-v4-pro"); err != nil {
364 t.Fatalf("SetModelForTab: %v", err)
365 }
366 if len(kernel.switchProxyCalls) != 1 || kernel.switchProxyCalls[0] != [5]string{"box", "~/app", "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro", fs.newSessionPath} {
367 t.Fatalf("credential proxy switch calls = %+v", kernel.switchProxyCalls)
368 }
369 for _, c := range fs.recorded() {
370 if strings.HasPrefix(c, "POST /model") {
371 t.Fatalf("serve saw %v, binding duplicated the kernel-owned switch", fs.recorded())
372 }
373 }
374 var current string
375 for _, m := range a.ModelsForTab(meta.ID) {
376 if m.Current {
377 current = m.Ref
378 }
379 }
380 if current != "deepseek/deepseek-v4-pro" {
381 t.Fatalf("current = %q, want deepseek/deepseek-v4-pro", current)
382 }
383 }
384
384 lines GO