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