返回 DeepSeek-Reasonix
attachment_regression_test.go
根目录 / internal / control / attachment_regression_test.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "path/filepath"
7 "reasonix/internal/agent"
8 "reasonix/internal/attachment"
9 "reasonix/internal/config"
10 "reasonix/internal/event"
11 "reasonix/internal/provider"
12 "reasonix/internal/sessioninbox"
13 "reasonix/internal/tool"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18 )
19
20 func TestAttachmentUploadUsesRequestRouteAndCancellation(t *testing.T) {
21 root := t.TempDir()
22 cfg := config.Default()
23 cfg.DefaultModel = "main/text"
24 cfg.Providers = []config.ProviderEntry{
25 {Name: "main", Kind: "openai", BaseURL: "https://api.deepseek.com", Models: []string{"text"}},
26 {Name: "vision", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", Models: []string{"vision"}},
27 {Name: "external", Kind: "openai", BaseURL: "https://vision.example.invalid/v1", Models: []string{"vision"}},
28 }
29 c := newOwnedTestController(t, Options{WorkspaceRoot: root, ModelRef: "main/text", ImageRouteConfig: cfg})
30 d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
31 if err != nil {
32 t.Fatal(err)
33 }
34 oldLimit, oldUpload := inlineImageLimit, uploadVisionFile
35 t.Cleanup(func() { inlineImageLimit = oldLimit; uploadVisionFile = oldUpload })
36 inlineImageLimit = 1
37 var uploads []provider.FileUpload
38 uploadVisionFile = func(_ context.Context, r provider.FileUpload) (string, error) {
39 uploads = append(uploads, r)
40 return "file-route", nil
41 }
42 msgs := []provider.Message{{Role: provider.RoleUser, Content: "image", ImageInputs: c.attachmentService().InputsFromRefs([]attachment.AttachmentRef{d.Ref})}}
43 if _, err := c.ResolveRequestImagesForModel(t.Context(), msgs, "vision/vision", true); err != nil {
44 t.Fatal(err)
45 }
46 if len(uploads) != 1 || uploads[0].BaseURL != cfg.Providers[1].BaseURL || uploads[0].Protocol != "anthropic" {
47 t.Fatalf("wrong route: %+v", uploads)
48 }
49 if _, err := c.ResolveRequestImagesForModel(t.Context(), msgs, "external/vision", true); err != nil {
50 t.Fatal(err)
51 }
52 if len(uploads) != 1 {
53 t.Fatal("external route uploaded to main provider")
54 }
55 uploadVisionFile = func(context.Context, provider.FileUpload) (string, error) { return "", context.Canceled }
56 if _, err := c.ResolveRequestImagesForModel(t.Context(), msgs, "vision/vision", true); !errors.Is(err, context.Canceled) {
57 t.Fatalf("cancel fell back inline: %v", err)
58 }
59 }
60
61 type reviewImageProvider struct {
62 requests chan provider.Request
63 textOnly bool
64 }
65
66 func (p *reviewImageProvider) Name() string { return "review" }
67 func (p *reviewImageProvider) ModelInfo() provider.ModelInfo {
68 if p.textOnly {
69 return provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText}}
70 }
71 return provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}}
72 }
73
74 func TestAttachmentTextModelUsesVisionProvider(t *testing.T) {
75 root := t.TempDir()
76 writeVisionTestConfig(t, root)
77 main := &reviewImageProvider{requests: make(chan provider.Request, 4), textOnly: true}
78 vision := &reviewImageProvider{requests: make(chan provider.Request, 4)}
79 ag := agent.New(main, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{ModelRef: "custom/text"}, event.Discard)
80 native := false
81 c := newOwnedTestController(t, Options{WorkspaceRoot: root, Runner: ag, Executor: ag, ModelRef: "custom/text", FrozenImageInput: &native, VisionModel: "custom/vision-pro", VisionProviderResolver: func(string) (provider.Provider, error) { return vision, nil }})
82 draft, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
83 if err != nil {
84 t.Fatal(err)
85 }
86 _, err = c.SubmitIdentified(SubmissionRequest{Input: "inspect", Attachments: []SubmissionAttachment{{ClientAttachmentID: "image-1", DraftID: draft.ID}}})
87 if err != nil {
88 t.Fatal(err)
89 }
90 select {
91 case req := <-vision.requests:
92 if len(req.Messages) != 1 || len(req.Messages[0].Images) != 1 {
93 t.Fatalf("vision request has no image: %+v", req.Messages)
94 }
95 case <-time.After(10 * time.Second):
96 t.Fatal("vision provider did not receive image")
97 }
98 select {
99 case req := <-main.requests:
100 for _, msg := range req.Messages {
101 if len(msg.Images) != 0 || len(msg.ImageInputs) != 0 {
102 t.Fatal("text model received raw image")
103 }
104 }
105 case <-time.After(10 * time.Second):
106 t.Fatal("text provider did not receive summary")
107 }
108 }
109
110 func TestAttachmentRetryAfterDraftRelease(t *testing.T) {
111 root := t.TempDir()
112 c := newOwnedTestController(t, Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl"), Sink: event.Discard})
113 d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
114 if err != nil {
115 t.Fatal(err)
116 }
117 req := SubmissionRequest{ID: "durable-image", Input: "inspect", Attachments: []SubmissionAttachment{{ClientAttachmentID: "stable-image", DraftID: d.ID}}}
118 first, err := c.submitIdentified(req, func() {
119 if err := c.prepareTurnAdmission(func(context.Context) error { return nil })(t.Context()); err != nil {
120 t.Fatal(err)
121 }
122 })
123 if err != nil {
124 t.Fatal(err)
125 }
126 c.ReleaseDraftImage(d.ID)
127 second, err := c.SubmitIdentified(req)
128 if err != nil || first != second {
129 t.Fatalf("retry = %+v, %v; want %+v", second, err, first)
130 }
131 }
132 func (p *reviewImageProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
133 p.requests <- req
134 ch := make(chan provider.Chunk, 2)
135 ch <- provider.Chunk{Type: provider.ChunkText, Text: "done"}
136 ch <- provider.Chunk{Type: provider.ChunkDone}
137 close(ch)
138 return ch, nil
139 }
140
141 func TestAttachmentRegressionDesktopImageReachesProvider(t *testing.T) {
142 for _, mode := range []string{"draft", "legacy"} {
143 t.Run(mode, func(t *testing.T) {
144 root := t.TempDir()
145 writeVisionTestConfig(t, root)
146 p := &reviewImageProvider{requests: make(chan provider.Request, 4)}
147 ag := agent.New(p, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
148 native := true
149 c := newOwnedTestController(t, Options{WorkspaceRoot: root, Runner: ag, Executor: ag, ModelRef: "custom/vision-pro", FrozenImageInput: &native})
150 input := "inspect "
151 if mode == "draft" {
152 d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
153 if err != nil {
154 t.Fatal(err)
155 }
156 input += "@draft:" + d.ID
157 } else {
158 ref, err := SaveImageDataURLInRoot(root, "data:image/png;base64,"+tinyPNG)
159 if err != nil {
160 t.Fatal(err)
161 }
162 input += "@" + ref
163 }
164 if _, err := c.SubmitIdentified(SubmissionRequest{Input: input}); err != nil {
165 t.Fatal(err)
166 }
167 select {
168 case req := <-p.requests:
169 var imgs []string
170 for _, m := range req.Messages {
171 if m.Role == provider.RoleUser {
172 imgs = append(imgs, m.Images...)
173 }
174 }
175 if len(imgs) != 1 || !strings.HasPrefix(imgs[0], "data:image/png;base64,") {
176 t.Fatalf("provider image inputs = %q, expected one resolved data URL", imgs)
177 }
178 case <-time.After(10 * time.Second):
179 t.Fatal("provider did not start")
180 }
181 })
182 }
183 }
184
185 func TestLegacyImagePathRemainsToolReadableWithoutVisionFallback(t *testing.T) {
186 root := t.TempDir()
187 ref, err := SaveImageDataURLInRoot(root, "data:image/png;base64,"+tinyPNG)
188 if err != nil {
189 t.Fatal(err)
190 }
191 provider := &reviewImageProvider{requests: make(chan provider.Request, 1), textOnly: true}
192 ag := agent.New(provider, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
193 textOnly := false
194 c := newOwnedTestController(t, Options{
195 WorkspaceRoot: root,
196 Runner: ag,
197 Executor: ag,
198 ModelRef: "custom/text-only",
199 FrozenImageInput: &textOnly,
200 })
201
202 prepared, err := c.PrepareSubmission(t.Context(), SubmissionRequest{Input: "inspect @" + ref})
203 if err != nil {
204 t.Fatal(err)
205 }
206 if !prepared.HasImages() {
207 t.Fatal("legacy image path was not frozen for the accepted turn")
208 }
209 if prepared.images.requiresImageUnderstanding {
210 t.Fatal("legacy image path should remain tool-readable when no vision fallback is configured")
211 }
212 if _, err := c.SubmitPreparedWithSetup(t.Context(), prepared, nil); err != nil {
213 t.Fatal(err)
214 }
215 select {
216 case req := <-provider.requests:
217 for _, msg := range req.Messages {
218 if len(msg.Images) != 0 || len(msg.ImageInputs) != 0 {
219 t.Fatalf("text-only provider received raw image: %+v", msg)
220 }
221 }
222 case <-time.After(10 * time.Second):
223 t.Fatal("text-only provider did not receive the tool-readable prompt")
224 }
225 }
226
227 func TestStructuredAttachmentRequiresVisionFallbackForTextModel(t *testing.T) {
228 root := t.TempDir()
229 textOnly := false
230 c := newOwnedTestController(t, Options{
231 WorkspaceRoot: root,
232 ModelRef: "custom/text-only",
233 FrozenImageInput: &textOnly,
234 })
235 draft, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
236 if err != nil {
237 t.Fatal(err)
238 }
239
240 _, err = c.PrepareSubmission(t.Context(), SubmissionRequest{
241 Input: "inspect",
242 Attachments: []SubmissionAttachment{{
243 ClientAttachmentID: "image-1",
244 DraftID: draft.ID,
245 }},
246 })
247 if err == nil || !strings.Contains(err.Error(), "no image understanding model is configured") {
248 t.Fatalf("PrepareSubmission error = %v, want missing vision fallback", err)
249 }
250 }
251
252 func TestAttachmentRegressionQueuePreservesDraft(t *testing.T) {
253 c := newOwnedTestController(t, Options{WorkspaceRoot: t.TempDir()})
254 d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
255 if err != nil {
256 t.Fatal(err)
257 }
258 var env sessioninbox.PromptEnvelope
259 if err := c.freezeInboxEnvelopeReferences(t.Context(), &env, "inspect @draft:"+d.ID, nil); err != nil {
260 t.Fatal(err)
261 }
262 if len(env.ImageInputs) != 1 {
263 t.Fatalf("queued image inputs = %d, want 1", len(env.ImageInputs))
264 }
265 }
266
267 func TestAttachmentRegressionDraftAdmissionLimitsAndDedup(t *testing.T) {
268 c := newOwnedTestController(t, Options{WorkspaceRoot: t.TempDir()})
269 d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
270 if err != nil {
271 t.Fatal(err)
272 }
273 p, fail := c.prepareSubmissionImages(SubmissionRequest{Input: "inspect @draft:" + d.ID, DraftIDs: []string{d.ID}})
274 if len(fail) > 0 {
275 t.Fatal(fail)
276 }
277 if len(p.inputs) != 1 {
278 t.Errorf("one UI draft resolved to %d images", len(p.inputs))
279 }
280 ids := make([]string, 21)
281 for i := range ids {
282 x, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
283 if err != nil {
284 t.Fatal(err)
285 }
286 ids[i] = x.ID
287 }
288 _, fail = c.prepareSubmissionImages(SubmissionRequest{Input: "inspect", DraftIDs: ids})
289 if len(fail) == 0 {
290 t.Error("21 image drafts admitted despite max 20 policy")
291 }
292 }
293
294 func TestAttachmentRegressionDraftAndLegacyAreBothPrepared(t *testing.T) {
295 root := t.TempDir()
296 c := newOwnedTestController(t, Options{WorkspaceRoot: root})
297 d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
298 if err != nil {
299 t.Fatal(err)
300 }
301 ref, err := SaveImageDataURLInRoot(root, "data:image/png;base64,"+tinyPNG)
302 if err != nil {
303 t.Fatal(err)
304 }
305 p, fail := c.prepareSubmissionImages(SubmissionRequest{Input: "inspect @draft:" + d.ID + " @" + ref})
306 if len(fail) > 0 {
307 t.Fatal(fail)
308 }
309 if len(p.inputs) != 2 {
310 t.Fatalf("mixed image inputs = %d, want 2", len(p.inputs))
311 }
312 }
313
314 func TestAttachmentRegressionAttachmentReceiptLookup(t *testing.T) {
315 root := t.TempDir()
316 c := newOwnedTestController(t, Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl"), Sink: event.Discard})
317 d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
318 if err != nil {
319 t.Fatal(err)
320 }
321 req := SubmissionRequest{ID: "with-image", Input: "inspect @draft:" + d.ID, DraftIDs: []string{d.ID}}
322 _, err = c.submitIdentified(req, func() {
323 if err := c.prepareTurnAdmission(func(context.Context) error { return nil })(context.Background()); err != nil {
324 t.Fatal(err)
325 }
326 })
327 if err != nil {
328 t.Fatal(err)
329 }
330 _, found, err := c.LookupSubmission(req)
331 if err != nil || !found {
332 t.Fatalf("same submission lookup: found=%v err=%v", found, err)
333 }
334 }
335
336 func TestAttachmentRegressionConcurrentDraftStaging(t *testing.T) {
337 c := newOwnedTestController(t, Options{WorkspaceRoot: t.TempDir()})
338 start := make(chan struct{})
339 var wg sync.WaitGroup
340 for range 4 {
341 wg.Go(func() {
342 <-start
343 _, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
344 if err != nil {
345 t.Error(err)
346 }
347 })
348 }
349 close(start)
350 wg.Wait()
351 }
352
352 lines GO