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