返回 DeepSeek-Reasonix
remote_model_settings_test.go
根目录 / desktop / remote_model_settings_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "testing"
12 "time"
13
14 "reasonix/internal/boot"
15 "reasonix/internal/config"
16 "reasonix/internal/netclient"
17 "reasonix/internal/provider"
18 "reasonix/internal/serve"
19 )
20
21 func TestRemoteModelOfferCapacityPreservesOwnedRoutes(t *testing.T) {
22 p := &credentialProxy{routes: map[string]*credProxyRoute{}}
23 scope := credentialProxyScope("host", "workspace")
24 upstream := mustParseURL(t, "http://127.0.0.1:8123")
25 reserve := func(id string) error {
26 _, err := p.resolveAndSetRoute("same-version", "p/m", func() (proxyUpstream, error) {
27 return proxyUpstream{url: upstream, scope: scope, offerID: id}, nil
28 })
29 return err
30 }
31 for i := range 64 {
32 if err := reserve(fmt.Sprint(i)); err != nil {
33 t.Fatal(err)
34 }
35 }
36 owned := p.routes["same-version"]
37 if err := reserve("overflow"); err == nil || p.routes["same-version"] != owned || len(owned.holds) != 64 {
38 t.Fatal("excess offer displaced an existing owner")
39 }
40 if err := reserve("0"); err != nil {
41 t.Fatal("idempotent reservation rejected", err)
42 }
43 app := &App{credProxy: p}
44 app.finishCredentialProxyOffer("host", "workspace", "0")
45 if err := reserve("replacement"); err != nil {
46 t.Fatal(err)
47 }
48 }
49
50 func TestRemoteModelSourceRefreshesAutonomousHTTPRunAndRetiresOldRoute(t *testing.T) {
51 isolateDesktopUserDirs(t)
52 ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
53 defer cancel()
54 keys := make(chan string, 4)
55 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56 _, _ = io.Copy(io.Discard, r.Body)
57 keys <- r.Header.Get("Authorization")
58 w.Header().Set("Content-Type", "text/event-stream")
59 fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
60 }))
61 defer upstream.Close()
62 app := NewApp()
63 defer app.closeCredentialProxy()
64 view := ProviderView{Name: "source", Kind: "openai", BaseURL: upstream.URL, Models: []string{"m"}, NoProxy: true}
65 if _, err := app.SaveProviderWithKey(view, "old-source-key"); err != nil {
66 t.Fatal(err)
67 }
68 cfg, err := config.LoadModelRuntimeSnapshot(t.TempDir())
69 if err != nil {
70 t.Fatal(err)
71 }
72 port, err := app.credentialProxyPort()
73 if err != nil {
74 t.Fatal(err)
75 }
76 bundle, ref, err := app.buildRemoteModelSettings("source-host", "source-workspace", "source/m", port, cfg)
77 if err != nil {
78 t.Fatal(err)
79 }
80 bc := serve.NewBroadcaster()
81 opts := boot.Options{Model: ref, ModelSettings: bundle, WorkspaceRoot: t.TempDir(), SessionDir: t.TempDir(), Sink: bc}
82 old, err := boot.Build(ctx, opts)
83 if err != nil {
84 t.Fatal(err)
85 }
86 old.EnsureSessionPath()
87 srv := serve.New(old, bc, config.ServeConfig{AuthMode: "none"})
88 srv.SetControllerBuildOptions(opts)
89 defer srv.Close()
90 statusRequest := httptest.NewRequest(http.MethodGet, "/model-settings", nil)
91 statusRequest.Host = "127.0.0.1"
92 statusResponse := httptest.NewRecorder()
93 srv.Handler().ServeHTTP(statusResponse, statusRequest)
94 var initialOwnership remoteModelSettingsStatus
95 if err := json.Unmarshal(statusResponse.Body.Bytes(), &initialOwnership); err != nil || !app.pinCredentialProxyOwnership("source-host", "source-workspace", initialOwnership) {
96 t.Fatal("could not establish source ownership", err)
97 }
98 app.finishCredentialProxyOffer("source-host", "source-workspace", bundle.OfferID)
99 if err := old.RunTurn(ctx, "first remote run"); err != nil {
100 t.Fatal(err)
101 }
102 if got := <-keys; got != "Bearer old-source-key" {
103 t.Fatal("initial remote route used wrong key")
104 }
105 if _, err := app.SaveProviderWithKey(view, "new-source-key"); err != nil {
106 t.Fatal(err)
107 }
108 frames, unsubscribe := bc.SubscribeAll()
109 defer unsubscribe()
110 request := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(`{"input":"autonomous next run"}`)).WithContext(ctx)
111 request.Host = "127.0.0.1"
112 request.Header.Set("Content-Type", "application/json")
113 request.Header.Set("Authorization", "Bearer "+srv.AuthToken())
114 response := httptest.NewRecorder()
115 srv.Handler().ServeHTTP(response, request)
116 if response.Code != http.StatusAccepted {
117 t.Fatalf("autonomous submit: %d %s", response.Code, response.Body)
118 }
119 select {
120 case got := <-keys:
121 if got != "Bearer new-source-key" {
122 t.Fatal("autonomous remote run retained the old key")
123 }
124 case <-ctx.Done():
125 t.Fatal(ctx.Err())
126 }
127 for {
128 select {
129 case frame := <-frames:
130 var message struct {
131 Kind string `json:"kind"`
132 }
133 _ = json.Unmarshal(frame, &message)
134 if message.Kind == "turn_done" {
135 app.credProxy.mu.Lock()
136 retired := app.credProxy.routes[bundle.SourceToken] == nil
137 holds := 0
138 for _, route := range app.credProxy.routes {
139 holds += len(route.holds)
140 }
141 app.credProxy.mu.Unlock()
142 if !retired || holds != 0 {
143 t.Fatalf("ownership did not release old route/offer: retired=%v holds=%d", retired, holds)
144 }
145 return
146 }
147 case <-ctx.Done():
148 t.Fatal(ctx.Err())
149 }
150 }
151 }
152
153 func TestRemoteModelOwnershipRetiresOldRouteAfterInFlightRequest(t *testing.T) {
154 isolateDesktopUserDirs(t)
155 started, release := make(chan string, 1), make(chan struct{})
156 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
157 defer cancel()
158 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
159 started <- r.Header.Get("Authorization")
160 select {
161 case <-release:
162 case <-r.Context().Done():
163 return
164 }
165 fmt.Fprint(w, "old request completed")
166 }))
167 defer upstream.Close()
168 app := NewApp()
169 defer app.closeCredentialProxy()
170 view := ProviderView{Name: "owned", Kind: "openai", BaseURL: upstream.URL, Models: []string{"m"}, NoProxy: true}
171 if _, err := app.SaveProviderWithKey(view, "old-owned-key"); err != nil {
172 t.Fatal(err)
173 }
174 cfg, err := config.LoadModelRuntimeSnapshot(t.TempDir())
175 if err != nil {
176 t.Fatal(err)
177 }
178 old, err := app.applyCredentialProxySnapshot("host", "workspace", "owned/m", cfg, "old")
179 if err != nil {
180 t.Fatal(err)
181 }
182 if _, err := app.SaveProviderWithKey(view, "new-owned-key"); err != nil {
183 t.Fatal(err)
184 }
185 nextCfg, err := config.LoadModelRuntimeSnapshot(t.TempDir())
186 if err != nil {
187 t.Fatal(err)
188 }
189 next, err := app.applyCredentialProxySnapshot("host", "workspace", "owned/m", nextCfg, "new")
190 if err != nil {
191 t.Fatal(err)
192 }
193 p := app.credProxy
194 request := httptest.NewRequest(http.MethodPost, "http://proxy/v1/chat/completions", strings.NewReader(`{"model":"m"}`)).WithContext(ctx)
195 request.Header.Set("Authorization", "Bearer "+old.token)
196 done := make(chan *httptest.ResponseRecorder, 1)
197 go func() { response := httptest.NewRecorder(); p.ServeHTTP(response, request); done <- response }()
198 select {
199 case key := <-started:
200 if key != "Bearer old-owned-key" {
201 t.Fatal("old request switched its credential")
202 }
203 case <-ctx.Done():
204 t.Fatal(ctx.Err())
205 }
206 status := remoteModelSettingsStatus{Version: 1, ModelSettingsOwnership: config.ModelSettingsOwnership{OwnershipIncarnation: "serve", OwnershipSeq: 1}, OwnedRevisions: []string{"old", "new"}}
207 app.pinCredentialProxyOwnership("host", "workspace", status)
208 app.reconcileCredentialProxyGenerations("host", "workspace", status)
209 p.mu.Lock()
210 preserved := p.routes[old.token] != nil && !p.routes[old.token].retired
211 p.mu.Unlock()
212 if !preserved {
213 t.Fatal("detached owner lost its route")
214 }
215 status.OwnershipSeq++
216 status.OwnedRevisions = []string{"new"}
217 app.reconcileCredentialProxyGenerations("host", "workspace", status)
218 p.mu.Lock()
219 retained := p.routes[old.token] != nil && p.routes[old.token].retired && p.routes[next.token] != nil
220 p.mu.Unlock()
221 if !retained {
222 t.Fatal("in-flight route removed early or new route retired")
223 }
224 rejected := httptest.NewRecorder()
225 p.ServeHTTP(rejected, request.Clone(ctx))
226 if rejected.Code != http.StatusUnauthorized {
227 t.Fatal("retired route accepted another request")
228 }
229 close(release)
230 select {
231 case response := <-done:
232 if response.Code != 200 || response.Body.String() != "old request completed" {
233 t.Fatalf("in-flight completion: %d", response.Code)
234 }
235 case <-ctx.Done():
236 t.Fatal(ctx.Err())
237 }
238 p.mu.Lock()
239 released := p.routes[old.token] == nil && p.routes[next.token] != nil
240 p.mu.Unlock()
241 if !released {
242 t.Fatal("completed old request retained its retired route")
243 }
244 }
245
246 func TestRemoteModelSnapshotPreservesWirePrefixAndKeepsKeysLocal(t *testing.T) {
247 isolateDesktopUserDirs(t)
248 root := t.TempDir()
249 t.Chdir(root)
250 for _, kind := range []string{"openai", "anthropic", "responses"} {
251 t.Run(kind, func(t *testing.T) {
252 requests := make(chan []byte, 8)
253 headers := make(chan http.Header, 8)
254 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
255 body, _ := io.ReadAll(r.Body)
256 requests <- body
257 headers <- r.Header.Clone()
258 w.Header().Set("Content-Type", "text/event-stream")
259 switch kind {
260 case "openai":
261 fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
262 case "anthropic":
263 fmt.Fprint(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"role\":\"assistant\",\"usage\":{\"input_tokens\":1}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
264 case "responses":
265 fmt.Fprint(w, "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
266 }
267 }))
268 defer upstream.Close()
269 app := NewApp()
270 defer app.closeCredentialProxy()
271 name, model := "wire-"+kind, "wire-model"
272 view := ProviderView{Name: name, Kind: kind, BaseURL: upstream.URL, Models: []string{model}, NoProxy: true, Headers: map[string]string{"X-Test-Private": "test-private-header"}}
273 // Generic custom headers are currently implemented by the chat and
274 // messages clients; Responses has its own identity-header contract.
275 if kind == "responses" {
276 view.Headers = nil
277 }
278 if _, err := app.SaveProviderWithKey(view, "test-real-credential"); err != nil {
279 t.Fatal(err)
280 }
281 cfg, err := config.LoadModelRuntimeSnapshot(root)
282 if err != nil {
283 t.Fatal(err)
284 }
285 ref := name + "/" + model
286 port, err := app.credentialProxyPort()
287 if err != nil {
288 t.Fatal(err)
289 }
290 bundle, remoteRef, err := app.buildRemoteModelSettings("host", "workspace", ref, port, cfg)
291 if err != nil {
292 t.Fatal(err)
293 }
294 wire, err := json.Marshal(bundle)
295 if err != nil {
296 t.Fatal(err)
297 }
298 if strings.Contains(string(wire), "test-real-credential") || strings.Contains(string(wire), "test-private-header") {
299 t.Fatal("remote bundle exposed a desktop credential")
300 }
301 remoteCfg := config.Default()
302 if err := bundle.Apply(remoteCfg, root); err != nil {
303 t.Fatal(err)
304 }
305 send := func(c *config.Config, ref string) []byte {
306 t.Helper()
307 p, err := boot.NewLocalProviderResolver(c, netclient.ProxySpec{Mode: netclient.ModeOff}).Resolve(provider.Selection{Ref: ref})
308 if err != nil {
309 t.Fatal(err)
310 }
311 stream, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleSystem, Content: "stable prefix\n"}, {Role: provider.RoleUser, Content: "hello"}}, Tools: []provider.ToolSchema{{Name: "example", Description: "stable schema", Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)}}})
312 if err != nil {
313 t.Fatal(err)
314 }
315 for chunk := range stream {
316 if chunk.Err != nil {
317 t.Fatal(chunk.Err)
318 }
319 }
320 return <-requests
321 }
322 before, after := send(cfg, ref), send(remoteCfg, remoteRef)
323 var direct, proxied map[string]json.RawMessage
324 if err := json.Unmarshal(before, &direct); err != nil {
325 t.Fatal(err)
326 }
327 if err := json.Unmarshal(after, &proxied); err != nil {
328 t.Fatal(err)
329 }
330 for _, field := range []string{"system", "messages", "input", "instructions", "tools"} {
331 if string(direct[field]) != string(proxied[field]) {
332 t.Fatalf("%s changed through snapshot proxy\ndirect=%s\nproxy=%s", field, direct[field], proxied[field])
333 }
334 }
335 for i := range 2 {
336 h := <-headers
337 key := h.Get("Authorization")
338 if kind == "anthropic" {
339 key = h.Get("x-api-key")
340 } else {
341 key = strings.TrimPrefix(key, "Bearer ")
342 }
343 if key != "test-real-credential" || h.Get("X-Test-Private") != view.Headers["X-Test-Private"] || h.Get(netclient.ModelProxyOriginalURLHeader) != "" {
344 t.Fatalf("upstream auth/headers incorrect for request %d", i)
345 }
346 }
347 })
348 }
349 }
350
351 func TestRemoteModelSettingsOldServeDoesNotReceiveMutation(t *testing.T) {
352 mutations := 0
353 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
354 if r.Method != http.MethodGet {
355 mutations++
356 }
357 w.WriteHeader(http.StatusNotFound)
358 }))
359 defer server.Close()
360 if _, err := remoteModelSettingsRequest(context.Background(), server.Client(), server.URL, "session", nil); err == nil || !strings.Contains(err.Error(), "newer remote Serve") {
361 t.Fatalf("capability error: %v", err)
362 }
363 if mutations != 0 {
364 t.Fatal("old remote received a mutation")
365 }
366 }
367
368 // A Serve older than the model-settings protocol answers unknown GET paths
369 // through its catch-all "GET /" route with status 200 and the HTML index, so
370 // the status probe must classify that document as a capability rejection
371 // instead of surfacing a JSON decode error (issue #9996).
372 func TestRemoteModelSettingsLegacyServeHTMLIndexIsCapabilityRejection(t *testing.T) {
373 mutations := 0
374 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
375 if r.Method == http.MethodGet {
376 w.Header().Set("Content-Type", "text/html; charset=utf-8")
377 w.WriteHeader(http.StatusOK)
378 _, _ = w.Write([]byte("<!DOCTYPE html><html><body>Reasonix</body></html>"))
379 return
380 }
381 mutations++
382 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
383 }))
384 defer server.Close()
385 for _, body := range []any{nil, map[string]any{"version": 1, "ref": "p/m", "settings": map[string]any{"revision": "r"}}} {
386 _, err := remoteModelSettingsRequest(context.Background(), server.Client(), server.URL, "session", body)
387 if err == nil || !strings.Contains(err.Error(), "newer remote Serve") {
388 t.Fatalf("capability error: %v", err)
389 }
390 if !isRemoteModelSettingsUnsupported(err) {
391 t.Fatalf("HTML index response was not classified as unsupported: %v", err)
392 }
393 if strings.Contains(err.Error(), "invalid character") {
394 t.Fatalf("raw JSON decode error escaped the capability probe: %v", err)
395 }
396 }
397 if mutations != 1 {
398 t.Fatalf("legacy serve received %d mutations", mutations)
399 }
400 }
401
402 // Only document-shaped bodies map to the legacy-Serve rejection; a corrupt or
403 // truncated status payload from a capable Serve stays a decode error.
404 func TestRemoteModelSettingsNonDocumentDecodeFailureStaysError(t *testing.T) {
405 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
406 w.Header().Set("Content-Type", "application/json")
407 _, _ = w.Write([]byte("not-json{"))
408 }))
409 defer server.Close()
410 _, err := remoteModelSettingsRequest(context.Background(), server.Client(), server.URL, "session", nil)
411 if err == nil || !strings.Contains(err.Error(), "decode remote model settings status") {
412 t.Fatalf("expected decode error, got %v", err)
413 }
414 if isRemoteModelSettingsUnsupported(err) {
415 t.Fatal("non-document payload was misclassified as an unsupported Serve")
416 }
417 }
418
419 type unsupportedModelSettingsKernel struct {
420 remoteKernel
421 switches int
422 }
423
424 func (k *unsupportedModelSettingsKernel) SwitchCredentialProxyModel(context.Context, string, string, string, string, string) error {
425 k.switches++
426 return &remoteModelSettingsRejection{message: remoteModelSettingsUpgradeHint, unsupported: true}
427 }
428
429 // Turn admission must not fail every send against a reused legacy Serve that
430 // credential mode itself still supports: the unsupported protocol is recorded
431 // per tab generation and the run is admitted without a revision, until a
432 // reconnect or serve replacement probes the protocol again.
433 func TestEnsureRemoteModelSettingsAdmitsLegacyServeTurns(t *testing.T) {
434 isolateDesktopUserDirs(t)
435 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
436 _, _ = w.Write([]byte("{}"))
437 }))
438 defer upstream.Close()
439 app := NewApp()
440 defer app.closeCredentialProxy()
441 view := ProviderView{Name: "legacy", Kind: "openai", BaseURL: upstream.URL, Models: []string{"m"}, NoProxy: true}
442 if _, err := app.SaveProviderWithKey(view, "legacy-key"); err != nil {
443 t.Fatal(err)
444 }
445 if err := editUserConfig(func(c *config.Config) error {
446 return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "legacy-host", Host: "127.0.0.1", CredentialMode: "local-proxy"})
447 }); err != nil {
448 t.Fatal(err)
449 }
450 tab := &remoteTab{
451 id: "legacy-tab", ref: RemoteTabRef{HostID: "legacy-host", Workspace: "ws"},
452 state: "ready", client: &http.Client{}, model: "legacy/m", gen: 1,
453 routing: remoteTabSessionRouting{currentPath: "/proj/session"},
454 }
455 app.remoteTabs = map[string]*remoteTab{tab.id: tab}
456 kernel := &unsupportedModelSettingsKernel{}
457 app.remoteMu.Lock()
458 app.remoteRuntime = kernel
459 app.remoteMu.Unlock()
460
461 revision, admittedGen, err := app.ensureRemoteModelSettings(tab.id)
462 if err != nil || revision != "" || admittedGen != 1 {
463 t.Fatalf("legacy Serve turn admission failed: revision=%q gen=%d err=%v", revision, admittedGen, err)
464 }
465 if kernel.switches != 1 {
466 t.Fatalf("expected one protocol probe, got %d", kernel.switches)
467 }
468 app.remoteTabMu.Lock()
469 recorded, failed := tab.settings.unsupportedGen == 1, tab.settings.failure
470 app.remoteTabMu.Unlock()
471 if !recorded || failed != "" {
472 t.Fatalf("unsupported generation not recorded cleanly: gen=%d failure=%q", tab.settings.unsupportedGen, failed)
473 }
474
475 // The remembered verdict admits later turns without re-probing the Serve.
476 if revision, admittedGen, err = app.ensureRemoteModelSettings(tab.id); err != nil || revision != "" || admittedGen != 1 || kernel.switches != 1 {
477 t.Fatalf("repeat admission re-probed legacy Serve: revision=%q gen=%d err=%v switches=%d", revision, admittedGen, err, kernel.switches)
478 }
479
480 // A new tab generation (reconnect or replaced Serve) probes once more.
481 app.remoteTabMu.Lock()
482 tab.gen = 2
483 app.remoteTabMu.Unlock()
484 if revision, admittedGen, err = app.ensureRemoteModelSettings(tab.id); err != nil || revision != "" || admittedGen != 2 || kernel.switches != 2 {
485 t.Fatalf("new generation did not re-probe the protocol: revision=%q gen=%d err=%v switches=%d", revision, admittedGen, err, kernel.switches)
486 }
487 }
488
489 type reconnectingUnsupportedKernel struct {
490 remoteKernel
491 switches int
492 reconnect func()
493 }
494
495 func (k *reconnectingUnsupportedKernel) SwitchCredentialProxyModel(context.Context, string, string, string, string, string) error {
496 k.switches++
497 if k.reconnect != nil {
498 reconnect := k.reconnect
499 k.reconnect = nil
500 reconnect()
501 }
502 return &remoteModelSettingsRejection{message: remoteModelSettingsUpgradeHint, unsupported: true}
503 }
504
505 // A reconnect that replaces the probe target mid-flight must not admit against
506 // the retired fence: the replacement Serve may speak the protocol, so the
507 // unsupported verdict is only remembered when the probed connection is still
508 // current, and a replaced generation is re-probed instead.
509 func TestEnsureRemoteModelSettingsReprobesReplacedConnection(t *testing.T) {
510 isolateDesktopUserDirs(t)
511 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
512 _, _ = w.Write([]byte("{}"))
513 }))
514 defer upstream.Close()
515 app := NewApp()
516 defer app.closeCredentialProxy()
517 view := ProviderView{Name: "legacy", Kind: "openai", BaseURL: upstream.URL, Models: []string{"m"}, NoProxy: true}
518 if _, err := app.SaveProviderWithKey(view, "legacy-key"); err != nil {
519 t.Fatal(err)
520 }
521 if err := editUserConfig(func(c *config.Config) error {
522 return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "legacy-host", Host: "127.0.0.1", CredentialMode: "local-proxy"})
523 }); err != nil {
524 t.Fatal(err)
525 }
526 tab := &remoteTab{
527 id: "legacy-tab", ref: RemoteTabRef{HostID: "legacy-host", Workspace: "ws"},
528 state: "ready", client: &http.Client{}, model: "legacy/m", gen: 1,
529 routing: remoteTabSessionRouting{currentPath: "/proj/session"},
530 }
531 app.remoteTabs = map[string]*remoteTab{tab.id: tab}
532 kernel := &reconnectingUnsupportedKernel{reconnect: func() {
533 app.remoteTabMu.Lock()
534 tab.gen++
535 app.remoteTabMu.Unlock()
536 }}
537 app.remoteMu.Lock()
538 app.remoteRuntime = kernel
539 app.remoteMu.Unlock()
540
541 revision, admittedGen, err := app.ensureRemoteModelSettings(tab.id)
542 if err != nil || revision != "" || admittedGen != 2 {
543 t.Fatalf("replaced connection admission failed: revision=%q gen=%d err=%v", revision, admittedGen, err)
544 }
545 if kernel.switches != 2 {
546 t.Fatalf("expected the replacement generation to be re-probed, switches=%d", kernel.switches)
547 }
548 app.remoteTabMu.Lock()
549 recorded := tab.settings.unsupportedGen == 2
550 app.remoteTabMu.Unlock()
551 if !recorded {
552 t.Fatalf("verdict was not recorded on the current generation: unsupportedGen=%d gen=%d", tab.settings.unsupportedGen, tab.gen)
553 }
554 }
555
556 // The application status must not leave legacy generations pending forever:
557 // a Serve without the protocol never applies snapshots, so the target reports
558 // not_required and the receipt stops waiting and polling.
559 func TestAppendRemoteModelSettingsReportsLegacyTargetNotRequired(t *testing.T) {
560 isolateDesktopUserDirs(t)
561 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
562 _, _ = w.Write([]byte("{}"))
563 }))
564 defer upstream.Close()
565 app := NewApp()
566 defer app.closeCredentialProxy()
567 view := ProviderView{Name: "legacy", Kind: "openai", BaseURL: upstream.URL, Models: []string{"m"}, NoProxy: true}
568 if _, err := app.SaveProviderWithKey(view, "legacy-key"); err != nil {
569 t.Fatal(err)
570 }
571 if err := editUserConfig(func(c *config.Config) error {
572 return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "legacy-host", Host: "127.0.0.1", CredentialMode: "local-proxy"})
573 }); err != nil {
574 t.Fatal(err)
575 }
576 tab := &remoteTab{
577 id: "legacy-tab", ref: RemoteTabRef{HostID: "legacy-host", Workspace: "ws"},
578 model: "legacy/m", gen: 3,
579 }
580 tab.settings.unsupportedGen = 3
581 app.remoteTabs = map[string]*remoteTab{tab.id: tab}
582
583 result := emptyModelSettingsResult()
584 app.appendRemoteModelSettingsStatus(&result)
585 if len(result.Targets) != 1 || result.Targets[0].Application != "not_required" {
586 t.Fatalf("legacy target not reported as not_required: %+v", result.Targets)
587 }
588 if result.Application == "pending" {
589 t.Fatal("legacy target drove the receipt into a pending application")
590 }
591 }
592
593 // Fresh and restored tabs run generation 0 with an unrecorded verdict; they
594 // must stay pending in the application status instead of claiming
595 // not_required before any capability probe has run.
596 func TestAppendRemoteModelSettingsFreshTabStaysPending(t *testing.T) {
597 isolateDesktopUserDirs(t)
598 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
599 _, _ = w.Write([]byte("{}"))
600 }))
601 defer upstream.Close()
602 app := NewApp()
603 defer app.closeCredentialProxy()
604 view := ProviderView{Name: "legacy", Kind: "openai", BaseURL: upstream.URL, Models: []string{"m"}, NoProxy: true}
605 if _, err := app.SaveProviderWithKey(view, "legacy-key"); err != nil {
606 t.Fatal(err)
607 }
608 if err := editUserConfig(func(c *config.Config) error {
609 return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "legacy-host", Host: "127.0.0.1", CredentialMode: "local-proxy"})
610 }); err != nil {
611 t.Fatal(err)
612 }
613 tab := &remoteTab{
614 id: "fresh-tab", ref: RemoteTabRef{HostID: "legacy-host", Workspace: "ws"},
615 model: "legacy/m",
616 }
617 app.remoteTabs = map[string]*remoteTab{tab.id: tab}
618
619 result := emptyModelSettingsResult()
620 app.appendRemoteModelSettingsStatus(&result)
621 if len(result.Targets) != 1 || result.Targets[0].Application != "pending" {
622 t.Fatalf("fresh tab not reported as pending: %+v", result.Targets)
623 }
624 if result.Application != "pending" {
625 t.Fatalf("fresh tab did not keep the receipt pending: %q", result.Application)
626 }
627 }
628
629 // A legacy Serve remains available for history, but cannot execute a new turn
630 // until it advertises the runtime and immutable-identity capabilities.
631 func TestSubmitRemoteTabRejectsLegacyServeWithoutExecutionProtocol(t *testing.T) {
632 isolateDesktopUserDirs(t)
633 submits := make(chan string, 4)
634 serve := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
635 if r.URL.Path == "/submit" {
636 submits <- r.Header.Get(expectedModelSettingsHeader)
637 w.WriteHeader(http.StatusNoContent)
638 return
639 }
640 http.Error(w, "unexpected path", http.StatusNotFound)
641 }))
642 defer serve.Close()
643 app := NewApp()
644 defer app.closeCredentialProxy()
645 view := ProviderView{Name: "legacy", Kind: "openai", BaseURL: serve.URL, Models: []string{"m"}, NoProxy: true}
646 if _, err := app.SaveProviderWithKey(view, "legacy-key"); err != nil {
647 t.Fatal(err)
648 }
649 if err := editUserConfig(func(c *config.Config) error {
650 return c.UpsertRemoteHost(config.RemoteHostEntry{Name: "legacy-host", Host: "127.0.0.1", CredentialMode: "local-proxy"})
651 }); err != nil {
652 t.Fatal(err)
653 }
654 tab := &remoteTab{
655 id: "legacy-tab", ref: RemoteTabRef{HostID: "legacy-host", Workspace: "ws"},
656 state: "ready", client: serve.Client(), base: serve.URL, model: "legacy/m", gen: 1,
657 routing: remoteTabSessionRouting{currentPath: "/proj/session"},
658 }
659 app.remoteTabs = map[string]*remoteTab{tab.id: tab}
660 kernel := &unsupportedModelSettingsKernel{}
661 app.remoteMu.Lock()
662 app.remoteRuntime = kernel
663 app.remoteMu.Unlock()
664
665 err := app.SubmitRemoteTab(tab.id, "first turn")
666 if err == nil || !strings.Contains(err.Error(), "execution-v2") || !strings.Contains(err.Error(), "session-history-v1") || !strings.Contains(err.Error(), "session-identity-v1") || !strings.Contains(err.Error(), "session-ownership-v1") {
667 t.Fatalf("legacy submit error = %v, want runtime protocol upgrade requirement", err)
668 }
669 if kernel.switches != 0 {
670 t.Fatalf("legacy target reached model admission before permission rejection, switches=%d", kernel.switches)
671 }
672 close(submits)
673 if seen := len(submits); seen != 0 {
674 t.Fatalf("legacy Serve received %d submit requests, want none", seen)
675 }
676 }
677
677 lines GO