| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "reasonix/internal/attachment" |
| 14 | "reasonix/internal/config" |
| 15 | "reasonix/internal/provider" |
| 16 | ) |
| 17 | |
| 18 | func TestHistoricalImageLossDoesNotBlockTextOrHealthyImages(t *testing.T) { |
| 19 | for _, damage := range []string{"missing", "corrupt", "metadata"} { |
| 20 | t.Run(damage, func(t *testing.T) { |
| 21 | root := t.TempDir() |
| 22 | c := newOwnedTestController(t, Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl"), ImageRouteConfig: config.Default()}) |
| 23 | draft, err := c.StageImage(t.Context(), "old.png", "image/png", "data:image/png;base64,"+tinyPNG) |
| 24 | if err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | digest := draft.Ref.Content.Digest |
| 28 | object := filepath.Join(c.attachmentService().Store().Root(), "objects", digest[:2], digest[2:4], digest) |
| 29 | switch damage { |
| 30 | case "missing": |
| 31 | err = os.Remove(object) |
| 32 | case "corrupt": |
| 33 | err = os.WriteFile(object, []byte("corrupt"), 0o600) |
| 34 | case "metadata": |
| 35 | draft.Ref.Width = 0 |
| 36 | } |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | messages := []provider.Message{ |
| 41 | {ID: "old", Role: provider.RoleUser, Content: "old text", ImageInputs: []attachment.ImageInput{ |
| 42 | {Kind: attachment.KindAttachment, Attachment: &draft.Ref}, |
| 43 | {Kind: attachment.KindURL, URL: "https://example.invalid/healthy.png"}, |
| 44 | }}, |
| 45 | {ID: "next", Role: provider.RoleUser, Content: "continue with text"}, |
| 46 | } |
| 47 | before, _ := json.Marshal(messages) |
| 48 | got, err := c.ResolveRequestImagesForModel(t.Context(), messages, "fixture/vision", true) |
| 49 | if err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if len(got[0].Images) != 1 || !strings.Contains(got[0].Content, "unavailable") || got[1].Content != messages[1].Content { |
| 53 | t.Fatalf("recovered messages = %+v", got) |
| 54 | } |
| 55 | after, _ := json.Marshal(messages) |
| 56 | if !bytes.Equal(before, after) { |
| 57 | t.Fatal("recovery rewrote canonical image references") |
| 58 | } |
| 59 | if _, err := c.ResolveRequestImagesForModel(t.Context(), messages[:1], "fixture/vision", true); err == nil { |
| 60 | t.Fatal("current-turn image failure was ignored") |
| 61 | } |
| 62 | ctx, cancel := context.WithCancel(t.Context()) |
| 63 | cancel() |
| 64 | if _, err := c.ResolveRequestImagesForModel(ctx, messages, "fixture/vision", true); !errors.Is(err, context.Canceled) { |
| 65 | t.Fatalf("cancellation was swallowed: %v", err) |
| 66 | } |
| 67 | }) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestImageRouteConfigurationErrorIsNotCached(t *testing.T) { |
| 72 | root := t.TempDir() |
| 73 | // NUL makes path resolution fail on every supported OS. A regular file |
| 74 | // used as a parent is ENOTDIR on Unix but missing-path fallback on Windows. |
| 75 | c := &Controller{workspaceRoot: filepath.Join(root, "invalid\x00root")} |
| 76 | if _, err := c.imageRequestRoute("custom/vision-pro"); err == nil { |
| 77 | t.Fatal("unreadable configuration accepted") |
| 78 | } |
| 79 | // Correct the source and retry on the same controller: the failed load |
| 80 | // must not mark its route snapshot ready or memoize the error. |
| 81 | c.workspaceRoot = root |
| 82 | writeVisionTestConfig(t, root) |
| 83 | got, err := c.imageRequestRoute("custom/vision-pro") |
| 84 | if err != nil || got.BaseURL != "https://example.invalid/v1" { |
| 85 | t.Fatalf("corrected config remained blocked: %+v, %v", got, err) |
| 86 | } |
| 87 | } |
| 88 |