返回 DeepSeek-Reasonix
inputimages_test.go
根目录 / internal / control / inputimages_test.go
1 package control
2
3 import (
4 "bytes"
5 "context"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/attachment"
14 "reasonix/internal/config"
15 "reasonix/internal/provider"
16 "reasonix/internal/sessioninbox"
17 )
18
19 func writeVisionTestConfig(t *testing.T, root string) {
20 t.Helper()
21 cfg := config.Default()
22 cfg.DefaultModel = "custom/vision-pro"
23 cfg.Providers = []config.ProviderEntry{{
24 Name: "custom",
25 Kind: "openai",
26 BaseURL: "https://example.invalid/v1",
27 Models: []string{"text-only", "vision-pro"},
28 VisionModels: []string{"vision-pro"},
29 }}
30 if err := cfg.SaveTo(filepath.Join(root, "reasonix.toml")); err != nil {
31 t.Fatalf("save config: %v", err)
32 }
33 }
34
35 func TestControllerInputImagesResolvesAttachment(t *testing.T) {
36 dir := t.TempDir()
37 t.Chdir(dir)
38 writeVisionTestConfig(t, dir)
39 ref, err := SaveImageDataURL("data:image/png;base64," + tinyPNG)
40 if err != nil {
41 t.Fatalf("SaveImageDataURL: %v", err)
42 }
43 urls := (&Controller{workspaceRoot: dir, selection: modelSelection{ref: "custom/vision-pro"}}).inputImages("look at @" + ref)
44 if len(urls) != 1 {
45 t.Fatalf("inputImages = %v, want one resolved data URL", urls)
46 }
47 if !strings.HasPrefix(urls[0], "data:image/png;base64,") {
48 t.Errorf("resolved url = %q, want a png data URL", urls[0])
49 }
50 }
51
52 func TestControllerInputImagesIgnoresNonAttachmentRefs(t *testing.T) {
53 t.Chdir(t.TempDir())
54 if urls := newOwnedTestController(t, Options{}).inputImages("plain text with @missing.png"); len(urls) != 0 {
55 t.Errorf("inputImages = %v, want none for a non-existent / non-attachment ref", urls)
56 }
57 }
58
59 func TestDetectRefsOnlyKeepsMissingImageAttachments(t *testing.T) {
60 c := &Controller{workspaceRoot: t.TempDir()}
61 refs := c.detectRefs("inspect @.reasonix/attachments/missing.png and @.reasonix/attachments/missing.pdf")
62 if len(refs) != 1 || refs[0].kind != refImage || refs[0].path != ".reasonix/attachments/missing.png" {
63 t.Fatalf("refs = %+v, want only the missing image attachment", refs)
64 }
65 }
66
67 func TestControllerInputImagesResolvesWorkspaceImage(t *testing.T) {
68 workspace := t.TempDir()
69 writeVisionTestConfig(t, workspace)
70 path := filepath.Join(workspace, "docs", "diagram.png")
71 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
72 t.Fatal(err)
73 }
74 if err := os.WriteFile(path, mustBase64(t, tinyPNG), 0o644); err != nil {
75 t.Fatal(err)
76 }
77
78 urls := (&Controller{workspaceRoot: workspace, selection: modelSelection{ref: "custom/vision-pro"}}).inputImages("look at @docs/diagram.png")
79 if len(urls) != 1 {
80 t.Fatalf("inputImages = %v, want one resolved data URL", urls)
81 }
82 if !strings.HasPrefix(urls[0], "data:image/png;base64,") {
83 t.Errorf("resolved url = %q, want a png data URL", urls[0])
84 }
85 }
86
87 func TestControllerInputImagesResolvesAttachmentOutsideProcessCWD(t *testing.T) {
88 workspace := t.TempDir()
89 processDir := t.TempDir()
90 writeVisionTestConfig(t, workspace)
91 path, err := SaveImageBytesInRoot(workspace, "image/png", mustBase64(t, tinyPNG))
92 if err != nil {
93 t.Fatal(err)
94 }
95 t.Chdir(processDir)
96 c := &Controller{workspaceRoot: workspace, selection: modelSelection{ref: "custom/vision-pro"}}
97 urls := c.inputImages("look at @" + filepath.ToSlash(path))
98 if len(urls) != 1 || !strings.HasPrefix(urls[0], "data:image/png;base64,") {
99 t.Fatalf("inputImages = %v, want one workspace-owned image", urls)
100 }
101 }
102
103 func TestSubmitIdentifiedRejectsMissingExplicitImageBeforeRunner(t *testing.T) {
104 workspace := t.TempDir()
105 runner := &recordingSessionRunner{session: agent.NewSession("sys")}
106 c := newOwnedTestController(t, Options{WorkspaceRoot: workspace, Runner: runner})
107 _, err := c.SubmitIdentified(SubmissionRequest{
108 Input: "inspect @.reasonix/attachments/missing.png", Display: "inspect image",
109 })
110 var failures ImageReferenceFailures
111 if !errors.As(err, &failures) || len(failures) != 1 || failures[0].Code != ImageReferenceMissing {
112 t.Fatalf("error = %#v, want one missing image failure", err)
113 }
114 if len(runner.inputs) != 0 {
115 t.Fatalf("runner inputs = %v, want no model call", runner.inputs)
116 }
117 }
118
119 func TestDirectAndEditedSubmissionsRejectMissingExplicitImageBeforeRunner(t *testing.T) {
120 const input = "inspect @.reasonix/attachments/missing.png"
121 for _, tc := range []struct {
122 name string
123 submit func(*Controller)
124 }{
125 {name: "direct", submit: func(c *Controller) { c.SubmitDisplay("inspect image", input) }},
126 {name: "edited", submit: func(c *Controller) { c.SubmitEditedDisplay("inspect image", input, "old prompt") }},
127 } {
128 t.Run(tc.name, func(t *testing.T) {
129 runner := &recordingSessionRunner{session: agent.NewSession("sys")}
130 c := newOwnedTestController(t, Options{WorkspaceRoot: t.TempDir(), Runner: runner})
131 tc.submit(c)
132 if len(runner.inputs) != 0 {
133 t.Fatalf("runner inputs = %v, want no model call", runner.inputs)
134 }
135 })
136 }
137 }
138
139 func TestPreparedAttachmentSurvivesWorkspaceFileDeletion(t *testing.T) {
140 workspace := t.TempDir()
141 ref, err := SaveImageBytesInRoot(workspace, "image/png", mustBase64(t, tinyPNG))
142 if err != nil {
143 t.Fatal(err)
144 }
145 c := newOwnedTestController(t, Options{WorkspaceRoot: workspace})
146 prepared, failures := c.prepareExplicitImageReferences("inspect @" + filepath.ToSlash(ref))
147 if len(failures) != 0 || len(prepared.inputs) != 1 {
148 t.Fatalf("prepared = %+v failures = %v", prepared, failures)
149 }
150 if err := os.Remove(filepath.Join(workspace, filepath.FromSlash(ref))); err != nil {
151 t.Fatal(err)
152 }
153 raw, err := c.attachmentService().ReadVerified(t.Context(), *prepared.inputs[0].Attachment)
154 if err != nil {
155 t.Fatalf("persisted original should survive workspace deletion: %v", err)
156 }
157 if len(raw) == 0 {
158 t.Fatal("persisted original was empty")
159 }
160 variant, err := c.attachmentService().PrepareVariant(t.Context(), *prepared.inputs[0].Attachment, attachment.VariantPolicyV1)
161 if err != nil {
162 t.Fatalf("variant rebuild after workspace deletion: %v", err)
163 }
164 if len(variant.Bytes) == 0 {
165 t.Fatal("rebuilt variant was empty")
166 }
167 }
168
169 func TestExplicitImagePreparationIsIndependentOfToolApprovalMode(t *testing.T) {
170 workspace := t.TempDir()
171 ref, err := SaveImageBytesInRoot(workspace, "image/png", mustBase64(t, tinyPNG))
172 if err != nil {
173 t.Fatal(err)
174 }
175 var want string
176 for _, mode := range []string{ToolApprovalWorkspaceWrite, ToolApprovalDangerFullAccess} {
177 t.Run(mode, func(t *testing.T) {
178 c := newOwnedTestController(t, Options{WorkspaceRoot: workspace})
179 c.SetToolApprovalMode(mode)
180 prepared, failures := c.prepareExplicitImageReferences("inspect @" + filepath.ToSlash(ref))
181 if len(failures) != 0 || len(prepared.ordered) != 1 {
182 t.Fatalf("prepared images = %v, failures = %v; want one frozen image", prepared.ordered, failures)
183 }
184 got := prepared.ordered[0]
185 if want == "" {
186 want = got
187 } else if got != want {
188 t.Fatal("permission profiles produced different image inputs")
189 }
190 })
191 }
192 }
193
194 func TestSubmitIdentifiedRejectsAllImagesWhenOneIsMissing(t *testing.T) {
195 workspace := t.TempDir()
196 valid, err := SaveImageBytesInRoot(workspace, "image/png", mustBase64(t, tinyPNG))
197 if err != nil {
198 t.Fatal(err)
199 }
200 runner := &recordingSessionRunner{session: agent.NewSession("sys")}
201 c := newOwnedTestController(t, Options{WorkspaceRoot: workspace, Runner: runner})
202 _, err = c.SubmitIdentified(SubmissionRequest{Input: "inspect @" + filepath.ToSlash(valid) + " @.reasonix/attachments/missing.png"})
203 var failures ImageReferenceFailures
204 if !errors.As(err, &failures) || len(failures) != 1 {
205 t.Fatalf("error = %#v, want partial-set rejection", err)
206 }
207 if len(runner.inputs) != 0 {
208 t.Fatalf("runner inputs = %v, want atomic rejection", runner.inputs)
209 }
210 }
211
212 func TestSubmitIdentifiedClassifiesExplicitImageFailuresBeforeRunner(t *testing.T) {
213 workspace := t.TempDir()
214 attachments := filepath.Join(workspace, ".reasonix", "attachments")
215 if err := os.MkdirAll(attachments, 0o755); err != nil {
216 t.Fatal(err)
217 }
218 if err := os.WriteFile(filepath.Join(attachments, "corrupt.png"), []byte("not an image"), 0o644); err != nil {
219 t.Fatal(err)
220 }
221 if err := os.WriteFile(filepath.Join(workspace, ".reasonix", "outside.png"), mustBase64(t, tinyPNG), 0o644); err != nil {
222 t.Fatal(err)
223 }
224 tooLarge := filepath.Join(attachments, "too-large.png")
225 if err := os.WriteFile(tooLarge, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil {
226 t.Fatal(err)
227 }
228 if err := os.Truncate(tooLarge, maxImageAttachmentBytes+1); err != nil {
229 t.Fatal(err)
230 }
231 linkPath := filepath.Join(attachments, "link.png")
232 symlinkAvailable := os.Symlink(filepath.Join(workspace, ".reasonix", "outside.png"), linkPath) == nil
233
234 cases := []struct {
235 name string
236 ref string
237 code ImageReferenceFailureCode
238 }{
239 {name: "corrupt", ref: ".reasonix/attachments/corrupt.png", code: ImageReferenceUnsupported},
240 {name: "traversal", ref: ".reasonix/attachments/../outside.png", code: ImageReferenceUnsafe},
241 {name: "too large", ref: ".reasonix/attachments/too-large.png", code: ImageReferenceTooLarge},
242 }
243 if symlinkAvailable {
244 cases = append(cases, struct {
245 name string
246 ref string
247 code ImageReferenceFailureCode
248 }{name: "symlink", ref: ".reasonix/attachments/link.png", code: ImageReferenceUnsafe})
249 }
250
251 for _, tc := range cases {
252 t.Run(tc.name, func(t *testing.T) {
253 runner := &recordingSessionRunner{session: agent.NewSession("sys")}
254 c := newOwnedTestController(t, Options{WorkspaceRoot: workspace, Runner: runner})
255 _, err := c.SubmitIdentified(SubmissionRequest{Input: "inspect @" + tc.ref})
256 var failures ImageReferenceFailures
257 if !errors.As(err, &failures) || len(failures) != 1 || failures[0].Code != tc.code {
258 t.Fatalf("error = %#v, want one %s failure", err, tc.code)
259 }
260 if len(runner.inputs) != 0 {
261 t.Fatalf("runner inputs = %v, want no model call", runner.inputs)
262 }
263 })
264 }
265 }
266
267 func TestSubmitIdentifiedRejectsInvocationImageBeforePreparingInvocation(t *testing.T) {
268 workspace := t.TempDir()
269 runner := &recordingSessionRunner{session: agent.NewSession("sys")}
270 c := newOwnedTestController(t, Options{WorkspaceRoot: workspace, Runner: runner})
271 _, err := c.SubmitIdentified(SubmissionRequest{
272 Input: "inspect @.reasonix/attachments/missing.png",
273 Invocations: []InvocationRequest{{Name: "missing-skill", Kind: "skill"}},
274 })
275 var failures ImageReferenceFailures
276 if !errors.As(err, &failures) || len(failures) != 1 || failures[0].Code != ImageReferenceMissing {
277 t.Fatalf("error = %#v, want image admission failure", err)
278 }
279 if len(runner.inputs) != 0 {
280 t.Fatalf("runner inputs = %v, want no model call", runner.inputs)
281 }
282 }
283
284 func TestEnqueueInboxRejectsMissingExplicitImageWithoutDurableItem(t *testing.T) {
285 dir := t.TempDir()
286 sessionPath := filepath.Join(dir, "session.jsonl")
287 c := newOwnedTestController(t, Options{
288 WorkspaceRoot: dir,
289 SessionDir: dir,
290 SessionPath: sessionPath,
291 })
292 _, err := c.EnqueueInbox(InboxRequest{Submit: "inspect @.reasonix/attachments/missing.png"})
293 var failures ImageReferenceFailures
294 if !errors.As(err, &failures) || len(failures) != 1 || failures[0].Code != ImageReferenceMissing {
295 t.Fatalf("error = %#v, want missing image failure", err)
296 }
297 if snap := c.InboxSnapshot(); len(snap.Items) != 0 {
298 t.Fatalf("rejected image submission created inbox items: %+v", snap.Items)
299 }
300 }
301
302 func TestControllerInputImagesResolvesAbsoluteWorkspaceImage(t *testing.T) {
303 workspace := t.TempDir()
304 writeVisionTestConfig(t, workspace)
305 path := filepath.Join(workspace, "diagram.png")
306 if err := os.WriteFile(path, mustBase64(t, tinyPNG), 0o644); err != nil {
307 t.Fatal(err)
308 }
309
310 urls := (&Controller{workspaceRoot: workspace, selection: modelSelection{ref: "custom/vision-pro"}}).inputImages("look at @" + path)
311 if len(urls) != 1 {
312 t.Fatalf("inputImages = %v, want one resolved data URL", urls)
313 }
314 if !strings.HasPrefix(urls[0], "data:image/png;base64,") {
315 t.Errorf("resolved url = %q, want a png data URL", urls[0])
316 }
317 }
318
319 func TestControllerInputImagesRequiresWorkspaceForFileImageRefs(t *testing.T) {
320 dir := t.TempDir()
321 path := filepath.Join(dir, "diagram.png")
322 if err := os.WriteFile(path, mustBase64(t, tinyPNG), 0o644); err != nil {
323 t.Fatal(err)
324 }
325
326 urls := newOwnedTestController(t, Options{}).inputImages("look at @" + path)
327 if len(urls) != 0 {
328 t.Fatalf("inputImages without a workspace = %v, want no file image refs", urls)
329 }
330 }
331
332 func TestControllerInputImagesSkipsModelImagesWhenSelectedModelIsTextOnly(t *testing.T) {
333 workspace := t.TempDir()
334 cfg := config.Default()
335 cfg.DefaultModel = "custom/text-only"
336 cfg.Providers = []config.ProviderEntry{{
337 Name: "custom",
338 Kind: "openai",
339 BaseURL: "https://example.invalid/v1",
340 Models: []string{"text-only", "vision-pro"},
341 VisionModels: []string{"vision-pro"},
342 }}
343 if err := cfg.SaveTo(filepath.Join(workspace, "reasonix.toml")); err != nil {
344 t.Fatalf("save workspace config: %v", err)
345 }
346 path := filepath.Join(workspace, "diagram.png")
347 if err := os.WriteFile(path, mustBase64(t, tinyPNG), 0o644); err != nil {
348 t.Fatal(err)
349 }
350
351 c := &Controller{workspaceRoot: workspace, selection: modelSelection{ref: "custom/text-only"}}
352 if urls := c.inputImages("look at @diagram.png"); len(urls) != 0 {
353 t.Fatalf("text-only model should suppress image payloads, got %v", urls)
354 }
355
356 c.selection.ref = "custom/vision-pro"
357 if urls := c.inputImages("look at @diagram.png"); len(urls) != 1 {
358 t.Fatalf("vision model should keep image payloads, got %v", urls)
359 }
360 }
361
362 func TestControllerResolvesSubagentImageCandidatesForTextParent(t *testing.T) {
363 workspace := t.TempDir()
364 cfg := config.Default()
365 cfg.Providers = []config.ProviderEntry{{
366 Name: "custom",
367 Kind: "openai",
368 BaseURL: "https://example.invalid/v1",
369 Models: []string{"text-only", "vision-pro"},
370 VisionModels: []string{"vision-pro"},
371 }}
372 if err := cfg.SaveTo(filepath.Join(workspace, "reasonix.toml")); err != nil {
373 t.Fatalf("save workspace config: %v", err)
374 }
375 path := filepath.Join(workspace, "diagram.png")
376 if err := os.WriteFile(path, mustBase64(t, tinyPNG), 0o644); err != nil {
377 t.Fatal(err)
378 }
379
380 c := &Controller{workspaceRoot: workspace, selection: modelSelection{ref: "custom/text-only"}}
381 if urls := c.inputImages("look at @diagram.png"); len(urls) != 0 {
382 t.Fatalf("text-only parent should suppress its own image payload, got %v", urls)
383 }
384 if urls := c.resolveInputImageCandidates("look at @diagram.png"); len(urls) != 1 {
385 t.Fatalf("subagent image candidates = %v, want one image for a vision child", urls)
386 }
387 }
388
389 func TestControllerResolveTurnImagesReusesCandidatesForVisionParent(t *testing.T) {
390 workspace := t.TempDir()
391 writeVisionTestConfig(t, workspace)
392 path := filepath.Join(workspace, "diagram.png")
393 if err := os.WriteFile(path, mustBase64(t, tinyPNG), 0o644); err != nil {
394 t.Fatal(err)
395 }
396
397 c := &Controller{workspaceRoot: workspace, selection: modelSelection{ref: "custom/vision-pro"}}
398 userImages, candidates := c.resolveTurnImages("inspect @diagram.png")
399 if len(userImages) != 1 || len(candidates) != 1 {
400 t.Fatalf("turn images = %v, candidates = %v; want one image in both paths", userImages, candidates)
401 }
402 if &userImages[0] != &candidates[0] || userImages[0] != candidates[0] {
403 t.Fatal("vision parent and subagent candidates should reuse the same resolved image slice")
404 }
405
406 c.selection.ref = "custom/text-only"
407 userImages, candidates = c.resolveTurnImages("inspect @diagram.png")
408 if len(userImages) != 0 || len(candidates) != 1 {
409 t.Fatalf("text parent turn images = %v, candidates = %v; want candidates only", userImages, candidates)
410 }
411 }
412
413 func TestGoalRoundDoesNotInheritPriorTurnImageCandidates(t *testing.T) {
414 workspace := t.TempDir()
415 writeVisionTestConfig(t, workspace)
416 path := filepath.Join(workspace, "diagram.png")
417 if err := os.WriteFile(path, mustBase64(t, tinyPNG), 0o644); err != nil {
418 t.Fatal(err)
419 }
420
421 c := &Controller{workspaceRoot: workspace, selection: modelSelection{ref: "custom/text-only"}}
422 initial := c.prepareOrchestratedTurnImages(orchestratedTurn{
423 raw: "inspect the diagnostic",
424 imageRefs: "@diagram.png",
425 })
426 if len(initial.userImages) != 0 || len(initial.imageCandidates) != 1 {
427 t.Fatalf("initial turn images = %v, candidates = %v; want child-only candidate", initial.userImages, initial.imageCandidates)
428 }
429
430 ctx := agent.WithSubagentImageCandidates(context.Background(), initial.imageCandidates)
431 continuation := orchestratedTurn{goalRound: &goalRoundReservation{}, synthetic: true, raw: "continue the target"}
432 userImages, candidates := c.imagesForOrchestratedTurn(ctx, continuation)
433 if len(userImages) != 0 || len(candidates) != 0 {
434 t.Fatalf("new Goal round inherited prior images = %v, candidates = %v", userImages, candidates)
435 }
436
437 next := c.prepareOrchestratedTurnImages(orchestratedTurn{raw: "plain next user turn"})
438 ctx = agent.WithSubagentImageCandidates(ctx, next.imageCandidates)
439 userImages, candidates = c.imagesForOrchestratedTurn(ctx, continuation)
440 if len(userImages) != 0 || len(candidates) != 0 {
441 t.Fatalf("next user turn leaked prior image: images = %v, candidates = %v", userImages, candidates)
442 }
443 }
444
445 func TestControllerImageInputEnabledDoesNotFallbackFromUnknownRef(t *testing.T) {
446 workspace := t.TempDir()
447 writeVisionTestConfig(t, workspace)
448
449 c := &Controller{workspaceRoot: workspace, selection: modelSelection{ref: "deleted/model"}}
450 if c.imageInputEnabled() {
451 t.Fatal("unknown ref should not inherit image input from the default fallback model")
452 }
453 }
454
455 func TestResolveRefsVisionCapableImageDoesNotAskForOCR(t *testing.T) {
456 dir := t.TempDir()
457 t.Chdir(dir)
458 writeVisionTestConfig(t, dir)
459 const slashPath = ".reasonix/attachments/shot.png"
460 if err := os.MkdirAll(filepath.Dir(slashPath), 0o755); err != nil {
461 t.Fatal(err)
462 }
463 if err := os.WriteFile(slashPath, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil {
464 t.Fatal(err)
465 }
466
467 c := &Controller{workspaceRoot: dir, selection: modelSelection{ref: "custom/vision-pro"}}
468 block, errs := c.ResolveRefs(context.Background(), "这是什么? @"+slashPath)
469 if len(errs) != 0 {
470 t.Fatalf("ResolveRefs errors = %v", errs)
471 }
472 if !strings.Contains(block, `<image path="`+slashPath+`">`) || !strings.Contains(block, "attached as visual input") {
473 t.Fatalf("vision-capable attachment should mark visual input:\n%s", block)
474 }
475 if strings.Contains(block, "OCR/image/vision tool") || strings.Contains(block, "image bytes are not inlined") {
476 t.Fatalf("vision-capable attachment must not tell the model to OCR the file:\n%s", block)
477 }
478 if urls := c.inputImages("这是什么? @" + slashPath); len(urls) != 1 || !strings.HasPrefix(urls[0], "data:image/png;base64,") {
479 t.Fatalf("vision-capable inputImages = %v, want one png data URL", urls)
480 }
481 }
482
483 func TestResolveRefsUnreadableImageDoesNotClaimAttached(t *testing.T) {
484 dir := t.TempDir()
485 t.Chdir(dir)
486 writeVisionTestConfig(t, dir)
487 const imagePath = ".reasonix/attachments/empty.png"
488 if err := os.MkdirAll(filepath.Dir(imagePath), 0o755); err != nil {
489 t.Fatal(err)
490 }
491 if err := os.WriteFile(imagePath, nil, 0o644); err != nil {
492 t.Fatal(err)
493 }
494
495 c := &Controller{workspaceRoot: dir, selection: modelSelection{ref: "custom/vision-pro"}}
496 block, errs := c.ResolveRefs(t.Context(), "look at @"+imagePath)
497 if len(errs) != 1 || (!strings.Contains(errs[0], "between 1 byte and 64 MB") && !strings.Contains(errs[0], "exceeds the allowed size") && !strings.Contains(errs[0], "size_limit")) {
498 t.Fatalf("ResolveRefs errors = %v, want unreadable-image error", errs)
499 }
500 if strings.Contains(block, "attached as visual input") {
501 t.Fatalf("unreadable image claimed successful attachment:\n%s", block)
502 }
503 }
504
505 func TestFreezeInboxReferencesResolvesLargeImageOnce(t *testing.T) {
506 workspace := t.TempDir()
507 t.Chdir(workspace)
508 cfg := config.Default()
509 cfg.DefaultModel = "deepseek/deepseek-v4-flash-vision-exp"
510 cfg.Providers = []config.ProviderEntry{{
511 Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com",
512 Models: []string{"deepseek-v4-flash-vision-exp"}, VisionModels: []string{"deepseek-v4-flash-vision-exp"},
513 APIKeyEnv: "DEEPSEEK_API_KEY",
514 }}
515 if err := cfg.SaveTo(filepath.Join(workspace, "reasonix.toml")); err != nil {
516 t.Fatal(err)
517 }
518 previousLimit := inlineImageLimit
519 inlineImageLimit = 4
520 t.Cleanup(func() { inlineImageLimit = previousLimit })
521 uploads := 0
522 previousUpload := uploadVisionFile
523 uploadVisionFile = func(_ context.Context, _ provider.FileUpload) (string, error) {
524 uploads++
525 return "file-api-shared-resolution", nil
526 }
527 t.Cleanup(func() { uploadVisionFile = previousUpload })
528
529 imagePath := filepath.Join(workspace, ".reasonix", "attachments", "large.png")
530 if err := os.MkdirAll(filepath.Dir(imagePath), 0o755); err != nil {
531 t.Fatal(err)
532 }
533 if err := os.WriteFile(imagePath, mustBase64(t, tinyPNG), 0o644); err != nil {
534 t.Fatal(err)
535 }
536 c := newOwnedTestController(t, Options{WorkspaceRoot: workspace})
537 c.selection.ref = cfg.DefaultModel
538 env := sessioninbox.PromptEnvelope{SubmitText: "look", ExplicitRefs: []string{filepath.ToSlash(filepath.Join(".reasonix", "attachments", "large.png"))}}
539 if err := c.freezeInboxEnvelopeReferences(t.Context(), &env, env.SubmitText, env.ExplicitRefs); err != nil {
540 t.Fatal(err)
541 }
542 if len(env.ImageInputs) != 1 || env.ImageInputs[0].Kind != attachment.KindAttachment {
543 t.Fatalf("image inputs = %+v, want one persisted attachment", env.ImageInputs)
544 }
545 if len(env.FrozenImages) != 0 {
546 t.Fatalf("frozen images = %v, want none before request preparation", env.FrozenImages)
547 }
548 if uploads != 0 {
549 t.Fatalf("image uploads = %d, want none until request preparation", uploads)
550 }
551 if !strings.Contains(env.FrozenRefBlock, "attached as visual input") {
552 t.Fatalf("successful freeze did not produce the visual-input note:\n%s", env.FrozenRefBlock)
553 }
554 }
555
556 func TestControllerInputImagesPassesHTTPURLAndFileID(t *testing.T) {
557 workspace := t.TempDir()
558 writeVisionTestConfig(t, workspace)
559 c := &Controller{workspaceRoot: workspace, selection: modelSelection{ref: "custom/vision-pro"}}
560 urls := c.inputImages("see @https://cdn.example.com/cat.png and @file-api-0a1b2c3d4e5f6071")
561 if len(urls) != 2 || urls[0] != "https://cdn.example.com/cat.png" || urls[1] != "file-api-0a1b2c3d4e5f6071" {
562 t.Fatalf("inputImages = %v, want URL then file_id", urls)
563 }
564 bare := c.inputImages("这是什么? https://cdn.example.com/dog.webp")
565 if len(bare) != 1 || bare[0] != "https://cdn.example.com/dog.webp" {
566 t.Fatalf("bare URL inputImages = %v", bare)
567 }
568 }
569
570 func TestControllerUploadsLargeOfficialDeepSeekImageViaFilesAPI(t *testing.T) {
571 workspace := t.TempDir()
572 t.Chdir(workspace)
573 cfg := config.Default()
574 cfg.DefaultModel = "deepseek/deepseek-v4-flash-vision-exp"
575 cfg.Providers = []config.ProviderEntry{{
576 Name: "deepseek",
577 Kind: "openai",
578 BaseURL: "https://api.deepseek.com",
579 Models: []string{"deepseek-v4-flash-vision-exp"},
580 VisionModels: []string{"deepseek-v4-flash-vision-exp"},
581 APIKeyEnv: "DEEPSEEK_API_KEY",
582 }}
583 if err := cfg.SaveTo(filepath.Join(workspace, "reasonix.toml")); err != nil {
584 t.Fatal(err)
585 }
586 prevLimit := inlineImageLimit
587 inlineImageLimit = 4
588 t.Cleanup(func() { inlineImageLimit = prevLimit })
589 prevUpload := uploadVisionFile
590 uploadVisionFile = func(_ context.Context, u provider.FileUpload) (string, error) {
591 if u.Protocol != "openai" || len(u.Data) <= 4 {
592 t.Fatalf("upload = %+v", u)
593 }
594 return "file-api-uploaded0001", nil
595 }
596 t.Cleanup(func() { uploadVisionFile = prevUpload })
597
598 path := filepath.Join(workspace, ".reasonix", "attachments", "big.png")
599 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
600 t.Fatal(err)
601 }
602 raw := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte("x"), 8)...)
603 if err := os.WriteFile(path, raw, 0o644); err != nil {
604 t.Fatal(err)
605 }
606 c := &Controller{workspaceRoot: workspace, selection: modelSelection{ref: "deepseek/deepseek-v4-flash-vision-exp"}}
607 got := c.inputImages("look at @.reasonix/attachments/big.png")
608 if len(got) != 1 || got[0] != "file-api-uploaded0001" {
609 t.Fatalf("inputImages = %v, want uploaded file_id", got)
610 }
611 }
612
612 lines GO