返回 DeepSeek-Reasonix
model_image_input_test.go
根目录 / desktop / model_image_input_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 "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
384 lines GO