| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "reflect" |
| 11 | "strings" |
| 12 | "sync/atomic" |
| 13 | "testing" |
| 14 | |
| 15 | "reasonix/internal/boot" |
| 16 | "reasonix/internal/config" |
| 17 | "reasonix/internal/control" |
| 18 | "reasonix/internal/event" |
| 19 | "reasonix/internal/netclient" |
| 20 | "reasonix/internal/provider" |
| 21 | ) |
| 22 | |
| 23 | // Exercises the public settings contract, persistent config, discovery cache, |
| 24 | // runtime factory and real HTTP serializers without external keys or probes. |
| 25 | func TestIDOnlyRelayImageInputSettingsToWire(t *testing.T) { |
| 26 | isolateDesktopUserDirs(t) |
| 27 | t.Chdir(t.TempDir()) |
| 28 | for _, kind := range []string{"openai", "anthropic", "responses"} { |
| 29 | t.Run(kind, func(t *testing.T) { |
| 30 | captured := make(chan []byte, 16) |
| 31 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 32 | if r.Method == http.MethodGet { |
| 33 | fmt.Fprint(w, `{"data":[{"id":"relay-model"}]}`) |
| 34 | return |
| 35 | } |
| 36 | body, _ := io.ReadAll(r.Body) |
| 37 | captured <- body |
| 38 | w.Header().Set("Content-Type", "text/event-stream") |
| 39 | switch kind { |
| 40 | case "openai": |
| 41 | fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") |
| 42 | case "anthropic": |
| 43 | 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") |
| 44 | case "responses": |
| 45 | 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") |
| 46 | } |
| 47 | })) |
| 48 | defer srv.Close() |
| 49 | app := NewApp() |
| 50 | view := ProviderView{Name: "image-test-" + kind, Kind: kind, BaseURL: srv.URL, Models: []string{"relay-model"}, NoProxy: true} |
| 51 | discover := func() []ProviderModelCapabilityView { |
| 52 | t.Helper() |
| 53 | v, err := app.FetchProviderModelCatalog(view) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | return v |
| 58 | } |
| 59 | facts := discover() |
| 60 | if len(facts) != 1 || facts[0].State != "unknown" || facts[0].InputModalities == nil { |
| 61 | t.Fatalf("ID-only = %+v", facts) |
| 62 | } |
| 63 | // A frontend-provided capability is not a discovery fact or a writable override. |
| 64 | view.ModelCapabilities = []ProviderModelCapabilityView{{Model: "relay-model", State: "supported", Source: "adapter", InputModalities: []string{"text", "image"}}} |
| 65 | load := func() *config.Config { |
| 66 | t.Helper() |
| 67 | c, err := config.LoadForRoot(".") |
| 68 | if err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | return c |
| 72 | } |
| 73 | send := func(c *config.Config, withImage bool) map[string]any { |
| 74 | t.Helper() |
| 75 | p, err := boot.NewLocalProviderResolver(c, netclient.ProxySpec{Mode: netclient.ModeOff}).Resolve(provider.Selection{Ref: view.Name + "/relay-model"}) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | messages := []provider.Message{{Role: provider.RoleSystem, Content: "stable system prefix"}, {Role: provider.RoleUser, Content: "describe"}} |
| 80 | if withImage { |
| 81 | messages[1].Images = []string{"data:image/png;base64,aGVsbG8="} |
| 82 | } |
| 83 | stream, err := p.Stream(context.Background(), provider.Request{Messages: messages, Tools: []provider.ToolSchema{{Name: "test_tool", Description: "stable tool", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)}}}) |
| 84 | if err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | for chunk := range stream { |
| 88 | if chunk.Err != nil { |
| 89 | t.Fatal(chunk.Err) |
| 90 | } |
| 91 | } |
| 92 | var body map[string]any |
| 93 | if err := json.Unmarshal(<-captured, &body); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | return body |
| 97 | } |
| 98 | if err := app.SaveProvider(view); err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | unknown := send(load(), true) |
| 102 | if strings.Contains(fmt.Sprint(unknown), "aGVsbG8=") { |
| 103 | t.Fatal("unknown sent native image") |
| 104 | } |
| 105 | textBefore := send(load(), false) |
| 106 | on := true |
| 107 | view.ModelOverrides = []ProviderModelOverrideView{{Model: "relay-model", Vision: &on, ContextWindow: 123456, MaxOutputTokens: 4321}} |
| 108 | if err := app.SaveProvider(view); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | enabled := send(load(), true) |
| 112 | serialized, _ := json.Marshal(enabled) |
| 113 | if !strings.Contains(string(serialized), "aGVsbG8=") { |
| 114 | t.Fatalf("enabled image missing: %s", serialized) |
| 115 | } |
| 116 | switch kind { |
| 117 | case "openai": |
| 118 | if !strings.Contains(string(serialized), `"type":"image_url"`) { |
| 119 | t.Fatal("Chat image block missing") |
| 120 | } |
| 121 | case "anthropic": |
| 122 | if !strings.Contains(string(serialized), `"type":"image"`) { |
| 123 | t.Fatal("Messages image block missing") |
| 124 | } |
| 125 | case "responses": |
| 126 | if !strings.Contains(string(serialized), `"type":"input_image"`) { |
| 127 | t.Fatal("Responses image block missing") |
| 128 | } |
| 129 | } |
| 130 | // Simulated restart: a fresh app and resolver read saved user choices; |
| 131 | // ID-only refresh cannot turn an override into a discovered positive fact. |
| 132 | app = NewApp() |
| 133 | facts = discover() |
| 134 | if facts[0].State != "supported" || facts[0].AutomaticState != "unknown" { |
| 135 | t.Fatalf("refresh lost separation: %+v", facts) |
| 136 | } |
| 137 | saved := load() |
| 138 | entry, _ := saved.ResolveModel(view.Name + "/relay-model") |
| 139 | if config.NewModelCapabilityResolver().Resolve(entry).State != config.CapabilitySupported { |
| 140 | t.Fatal("restart lost setting") |
| 141 | } |
| 142 | off := false |
| 143 | view.ModelOverrides[0].Vision = &off |
| 144 | if err := app.SaveProvider(view); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | disabled := send(load(), true) |
| 148 | if strings.Contains(fmt.Sprint(disabled), "aGVsbG8=") { |
| 149 | t.Fatal("disabled sent native image") |
| 150 | } |
| 151 | textOff := send(load(), false) |
| 152 | view.ModelOverrides[0].Vision = &on |
| 153 | if err := app.SaveProvider(view); err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | textOn := send(load(), false) |
| 157 | if !reflect.DeepEqual(textOn, textOff) { |
| 158 | t.Fatalf("image toggle changed text request: on=%v off=%v", textOn, textOff) |
| 159 | } |
| 160 | if !reflect.DeepEqual(textBefore["tools"], textOn["tools"]) { |
| 161 | t.Fatal("tool schema changed") |
| 162 | } |
| 163 | view.ModelOverrides[0].Vision = nil |
| 164 | if err := app.SaveProvider(view); err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | entry, _ = load().ResolveModel(view.Name + "/relay-model") |
| 168 | if got := config.NewModelCapabilityResolver().Resolve(entry); got.State != config.CapabilityUnknown { |
| 169 | t.Fatalf("auto = %+v", got) |
| 170 | } |
| 171 | if entry.ContextWindow != 123456 || entry.MaxOutputTokens != 4321 { |
| 172 | t.Fatal("auto erased unrelated overrides") |
| 173 | } |
| 174 | }) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | func TestImageInputSaveDefersAllTabsUntilNextTurn(t *testing.T) { |
| 179 | isolateDesktopUserDirs(t) |
| 180 | root := t.TempDir() |
| 181 | t.Chdir(root) |
| 182 | app := NewApp() |
| 183 | view := ProviderView{Name: "relay", Kind: "openai", BaseURL: "http://127.0.0.1:1", Models: []string{"relay-model"}} |
| 184 | if err := app.SaveProvider(view); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | if _, err := app.SetConnectionKey("relay", "test-key"); err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | build := func() *control.Controller { |
| 191 | t.Helper() |
| 192 | ctrl, err := boot.Build(context.Background(), boot.Options{Model: "relay/relay-model", WorkspaceRoot: root, Sink: event.Discard}) |
| 193 | if err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | return ctrl |
| 197 | } |
| 198 | active, other := build(), build() |
| 199 | app.ctx = context.Background() |
| 200 | app.readyHook = func() {} |
| 201 | app.setTestCtrl(active, "relay/relay-model") |
| 202 | app.activeTab().WorkspaceRoot = root |
| 203 | otherTab := &WorkspaceTab{ID: "other-image", Scope: "global", WorkspaceRoot: root, model: "relay/relay-model", Label: "relay/relay-model", Ctrl: other, Ready: true, disabledMCP: map[string]ServerView{}} |
| 204 | otherTab.sink = &tabEventSink{tabID: otherTab.ID, app: app, ctx: context.Background()} |
| 205 | app.tabs[otherTab.ID] = otherTab |
| 206 | installNoopRuntimeEvents(app, otherTab.sink) |
| 207 | t.Cleanup(func() { |
| 208 | for _, tab := range app.tabs { |
| 209 | if tab.Ctrl != nil { |
| 210 | tab.Ctrl.Close() |
| 211 | } |
| 212 | tab.releaseSessionLease() |
| 213 | } |
| 214 | }) |
| 215 | on := true |
| 216 | view.ModelOverrides = []ProviderModelOverrideView{{Model: "relay-model", Vision: &on}} |
| 217 | if err := app.SaveProvider(view); err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | if app.activeCtrl() != active || app.Meta().ImageInputEnabled { |
| 221 | t.Fatal("save replaced active runtime before the next run") |
| 222 | } |
| 223 | if app.MetaForTab(otherTab.ID).ImageInputEnabled || otherTab.Ctrl != other { |
| 224 | t.Fatal("other idle tab displayed saved config before rebuilding") |
| 225 | } |
| 226 | admission, current, err := app.beginTabTurn(otherTab.ID, false) |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | admission.abort() |
| 231 | if current == other || !app.MetaForTab(otherTab.ID).ImageInputEnabled { |
| 232 | t.Fatal("other tab admitted next turn with stale capability") |
| 233 | } |
| 234 | admission, current, err = app.beginTabTurn(app.activeTabID, false) |
| 235 | if err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | admission.abort() |
| 239 | if current == active || !app.Meta().ImageInputEnabled { |
| 240 | t.Fatal("active tab did not apply settings before admission") |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | func TestDiscoveryRejectsChangedIdentityWhileWaiting(t *testing.T) { |
| 245 | isolateDesktopUserDirs(t) |
| 246 | t.Chdir(t.TempDir()) |
| 247 | started, release := make(chan struct{}), make(chan struct{}) |
| 248 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 249 | close(started) |
| 250 | <-release |
| 251 | fmt.Fprint(w, `{"data":[{"id":"relay-model","vision":true}]}`) |
| 252 | })) |
| 253 | defer srv.Close() |
| 254 | app := NewApp() |
| 255 | view := ProviderView{Name: "relay", Kind: "openai", BaseURL: srv.URL, Models: []string{"relay-model"}} |
| 256 | if err := app.SaveProvider(view); err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | result := make(chan error, 1) |
| 260 | go func() { _, err := app.FetchProviderModelCatalog(view); result <- err }() |
| 261 | <-started |
| 262 | view.RequestURL = srv.URL + "/other/chat/completions" |
| 263 | if err := app.SaveProvider(view); err != nil { |
| 264 | close(release) |
| 265 | t.Fatal(err) |
| 266 | } |
| 267 | close(release) |
| 268 | if err := <-result; err == nil { |
| 269 | t.Fatal("stale discovery accepted after route edit") |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | func TestDiscoveryNewestSuccessfulRequestWins(t *testing.T) { |
| 274 | isolateDesktopUserDirs(t) |
| 275 | t.Chdir(t.TempDir()) |
| 276 | started, release := make(chan struct{}), make(chan struct{}) |
| 277 | var requests atomic.Int32 |
| 278 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 279 | if requests.Add(1) == 1 { |
| 280 | close(started) |
| 281 | <-release |
| 282 | fmt.Fprint(w, `{"data":[{"id":"relay-model","vision":true}]}`) |
| 283 | return |
| 284 | } |
| 285 | fmt.Fprint(w, `{"data":[{"id":"relay-model"}]}`) |
| 286 | })) |
| 287 | defer srv.Close() |
| 288 | app := NewApp() |
| 289 | view := ProviderView{Name: "relay", Kind: "openai", BaseURL: srv.URL, NoProxy: true} |
| 290 | old := make(chan []ProviderModelCapabilityView, 1) |
| 291 | errors := make(chan error, 1) |
| 292 | go func() { models, err := app.FetchProviderModelCatalog(view); old <- models; errors <- err }() |
| 293 | <-started |
| 294 | newer, err := app.FetchProviderModelCatalog(view) |
| 295 | close(release) |
| 296 | if err != nil || len(newer) != 1 || newer[0].State != "unknown" { |
| 297 | t.Fatalf("newer: %+v %v", newer, err) |
| 298 | } |
| 299 | older := <-old |
| 300 | if err := <-errors; err != nil || len(older) != 1 || older[0].State != "unknown" { |
| 301 | t.Fatalf("late positive response replaced unknown: %+v %v", older, err) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | func TestDiscoveryRejectsChangedCredentialsWhileWaiting(t *testing.T) { |
| 306 | isolateDesktopUserDirs(t) |
| 307 | t.Chdir(t.TempDir()) |
| 308 | started, release := make(chan struct{}), make(chan struct{}) |
| 309 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 310 | close(started) |
| 311 | <-release |
| 312 | fmt.Fprint(w, `{"data":[{"id":"relay-model","vision":true}]}`) |
| 313 | })) |
| 314 | defer srv.Close() |
| 315 | app := NewApp() |
| 316 | view := ProviderView{Name: "relay", Kind: "openai", BaseURL: srv.URL, NoProxy: true, APIKeyEnv: "REASONIX_IMAGE_TEST_KEY"} |
| 317 | view.Models = []string{"relay-model"} |
| 318 | if _, err := app.SaveProviderWithKey(view, "local-test-before"); err != nil { |
| 319 | t.Fatal(err) |
| 320 | } |
| 321 | for _, saved := range app.Settings().Providers { |
| 322 | if saved.Name == view.Name { |
| 323 | view = saved |
| 324 | } |
| 325 | } |
| 326 | result := make(chan error, 1) |
| 327 | go func() { _, err := app.FetchProviderModelCatalog(view); result <- err }() |
| 328 | <-started |
| 329 | if _, err := app.SetConnectionKey(view.Name, "local-test-after"); err != nil { |
| 330 | close(release) |
| 331 | t.Fatal(err) |
| 332 | } |
| 333 | close(release) |
| 334 | if err := <-result; err == nil { |
| 335 | t.Fatal("stale discovery accepted after credentials changed") |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | func TestDiscoveryFailurePreservesSuccessfulCache(t *testing.T) { |
| 340 | isolateDesktopUserDirs(t) |
| 341 | t.Chdir(t.TempDir()) |
| 342 | var mode atomic.Int32 |
| 343 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 344 | if mode.Load() == 1 { |
| 345 | http.Error(w, "temporary failure", http.StatusServiceUnavailable) |
| 346 | return |
| 347 | } |
| 348 | fmt.Fprint(w, `{"data":[{"id":"relay-model","vision":true}]}`) |
| 349 | })) |
| 350 | defer srv.Close() |
| 351 | app := NewApp() |
| 352 | view := ProviderView{Name: "relay", Kind: "openai", BaseURL: srv.URL, NoProxy: true, Models: []string{"relay-model"}} |
| 353 | if _, err := app.FetchProviderModelCatalog(view); err != nil { |
| 354 | t.Fatal(err) |
| 355 | } |
| 356 | mode.Store(1) |
| 357 | if _, err := app.FetchProviderModelCatalog(view); err == nil { |
| 358 | t.Fatal("expected discovery error") |
| 359 | } |
| 360 | e := config.ProviderEntry{Name: view.Name, Kind: view.Kind, BaseURL: view.BaseURL, NoProxy: true, Model: "relay-model"} |
| 361 | if got := config.NewModelCapabilityResolver().Resolve(&e); got.State != config.CapabilitySupported { |
| 362 | t.Fatalf("failure erased success: %+v", got) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | func TestBatchDiscoveryPersistsFactsForNoProxyIdentity(t *testing.T) { |
| 367 | isolateDesktopUserDirs(t) |
| 368 | t.Chdir(t.TempDir()) |
| 369 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 370 | fmt.Fprint(w, `{"data":[{"id":"relay-model","vision":true}]}`) |
| 371 | })) |
| 372 | defer srv.Close() |
| 373 | |
| 374 | view := ProviderView{Name: "relay", Kind: "openai", BaseURL: srv.URL, NoProxy: true, Models: []string{"relay-model"}} |
| 375 | got := NewApp().FetchAllProviderModelCatalogs([]ProviderView{view}) |
| 376 | if len(got[view.Name]) != 1 || got[view.Name][0].State != "supported" { |
| 377 | t.Fatalf("batch catalog = %+v", got) |
| 378 | } |
| 379 | entry := config.ProviderEntry{Name: view.Name, Kind: view.Kind, BaseURL: view.BaseURL, NoProxy: true, Model: "relay-model"} |
| 380 | if capability := config.NewModelCapabilityResolver().Resolve(&entry); capability.State != config.CapabilitySupported { |
| 381 | t.Fatalf("batch result was not stored under the no_proxy identity: %+v", capability) |
| 382 | } |
| 383 | } |
| 384 |