diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 22:50:11 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 22:50:11 +0900 |
| commit | 66e5d3b105bfd2b34c6f35876bf33dbb3cb9dcae (patch) | |
| tree | c3e039e09c89231f84dfd16f7bbbf8aedcc2dc7d /packages/core/tests/models | |
| parent | 4b45d33c256cf580a53054078be6fd7148fa6302 (diff) | |
| download | dispatch-66e5d3b105bfd2b34c6f35876bf33dbb3cb9dcae.tar.gz dispatch-66e5d3b105bfd2b34c6f35876bf33dbb3cb9dcae.zip | |
feat(chat): paste-to-attach images/PDFs with model capability check
Add multimodal image/PDF input to the chat box via clipboard paste, gated by a
graceful per-model capability check.
UX: a pasted image/PDF inserts an inline token (【image:…】 / 【pdf:…】) into the
draft, so attachments have ORDER relative to typed text and can be referenced
positionally. The token is the only handle — deleting it (atomic Backspace/
Delete, or selection overlap) detaches the file; an input-reconciliation safety
net detaches any attachment whose token is no longer intact. No preview strip.
Capability check: resolveModelCapabilities reads models.dev modalities.input
(new GET /models/capabilities, mirrors /context-limit). The input blocks Send
(no tokens spent) only on a definitive 'no'; unknown capability (catalog offline
/ unmapped provider) stays permissive. Attachments require a fresh turn — Send is
blocked while generating and /chat rejects content mid-turn (409).
Attachments are EPHEMERAL: forwarded to the model for the turn via ordered AI SDK
ImagePart/FilePart content, but never persisted (history keeps the text with
[image]/[pdf] markers). Text-only turns serialize byte-identically to before.
Limits (Anthropic-aligned, enforced at paste + re-validated server-side):
PNG/JPEG/WebP/GIF/PDF; image ≤5MB, PDF ≤32MB, ≤20 attachments, ≤32MB total.
core: UserContentPart types, models/attachments validator, capability resolver,
agent.run+toModelMessages thread ordered content. api: /chat content validation +
passthrough. frontend: attachment-tokens helper, ChatInput paste/token/gating,
per-tab staged attachments, App.svelte capability fetch. +44 tests.
Diffstat (limited to 'packages/core/tests/models')
| -rw-r--r-- | packages/core/tests/models/attachments.test.ts | 136 | ||||
| -rw-r--r-- | packages/core/tests/models/catalog.test.ts | 75 |
2 files changed, 208 insertions, 3 deletions
diff --git a/packages/core/tests/models/attachments.test.ts b/packages/core/tests/models/attachments.test.ts new file mode 100644 index 0000000..11a9f82 --- /dev/null +++ b/packages/core/tests/models/attachments.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { + base64ByteLength, + isAcceptedAttachmentMediaType, + isImageMediaType, + isPdfMediaType, + MAX_ATTACHMENTS, + MAX_IMAGE_BYTES, + MAX_PDF_BYTES, + MAX_TOTAL_ATTACHMENT_BYTES, + validateUserContent, +} from "../../src/models/attachments.js"; +import type { UserContentPart } from "../../src/types/index.js"; + +/** A base64 string that decodes to exactly `bytes` bytes (no padding chars). */ +function base64OfBytes(bytes: number): string { + // 4 base64 chars → 3 bytes. Use a multiple of 3 for clean (unpadded) output. + const groups = Math.ceil(bytes / 3); + return "A".repeat(groups * 4); +} + +function imagePart(data: string, mediaType = "image/png"): UserContentPart { + return { type: "attachment", mediaType, data }; +} + +describe("media-type predicates", () => { + it("classifies image types", () => { + expect(isImageMediaType("image/png")).toBe(true); + expect(isImageMediaType("image/jpeg")).toBe(true); + expect(isImageMediaType("image/webp")).toBe(true); + expect(isImageMediaType("image/gif")).toBe(true); + expect(isImageMediaType("application/pdf")).toBe(false); + expect(isImageMediaType("image/svg+xml")).toBe(false); + }); + + it("classifies pdf + accepted types", () => { + expect(isPdfMediaType("application/pdf")).toBe(true); + expect(isPdfMediaType("image/png")).toBe(false); + expect(isAcceptedAttachmentMediaType("image/gif")).toBe(true); + expect(isAcceptedAttachmentMediaType("application/pdf")).toBe(true); + expect(isAcceptedAttachmentMediaType("text/plain")).toBe(false); + }); +}); + +describe("base64ByteLength", () => { + it("computes decoded length without padding", () => { + // "AAAA" → 3 bytes. + expect(base64ByteLength("AAAA")).toBe(3); + }); + + it("accounts for padding", () => { + // "QQ==" → 1 byte ("A"). + expect(base64ByteLength("QQ==")).toBe(1); + // "QUI=" → 2 bytes ("AB"). + expect(base64ByteLength("QUI=")).toBe(2); + }); + + it("tolerates a data: URI prefix and whitespace", () => { + expect(base64ByteLength("data:image/png;base64,AAAA")).toBe(3); + expect(base64ByteLength("AA\nAA")).toBe(3); + }); + + it("returns 0 for empty input", () => { + expect(base64ByteLength("")).toBe(0); + expect(base64ByteLength(" ")).toBe(0); + }); +}); + +describe("validateUserContent", () => { + it("accepts a small image and ignores text parts", () => { + const content: UserContentPart[] = [ + { type: "text", text: "hi" }, + imagePart(base64OfBytes(1024)), + ]; + expect(validateUserContent(content)).toEqual({ ok: true, errors: [] }); + }); + + it("accepts an empty / text-only content list", () => { + expect(validateUserContent([]).ok).toBe(true); + expect(validateUserContent([{ type: "text", text: "no files" }]).ok).toBe(true); + }); + + it("rejects an unsupported media type", () => { + const res = validateUserContent([imagePart(base64OfBytes(10), "image/svg+xml")]); + expect(res.ok).toBe(false); + expect(res.errors[0]).toMatchObject({ code: "unsupported-type", mediaType: "image/svg+xml" }); + }); + + it("rejects an oversized image but allows a PDF of the same size", () => { + const big = base64OfBytes(MAX_IMAGE_BYTES + 3); + const imgRes = validateUserContent([imagePart(big, "image/png")]); + expect(imgRes.ok).toBe(false); + expect(imgRes.errors.some((e) => e.code === "image-too-large")).toBe(true); + + // Same byte size as a PDF is fine (PDF limit is much higher). + const pdfRes = validateUserContent([imagePart(big, "application/pdf")]); + expect(pdfRes.ok).toBe(true); + }); + + it("rejects an oversized PDF", () => { + const res = validateUserContent([ + imagePart(base64OfBytes(MAX_PDF_BYTES + 3), "application/pdf"), + ]); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.code === "pdf-too-large")).toBe(true); + }); + + it("rejects an empty attachment payload", () => { + const res = validateUserContent([imagePart("", "image/png")]); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.code === "empty")).toBe(true); + }); + + it("rejects too many attachments", () => { + const content: UserContentPart[] = Array.from({ length: MAX_ATTACHMENTS + 1 }, () => + imagePart(base64OfBytes(8)), + ); + const res = validateUserContent(content); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.code === "too-many")).toBe(true); + }); + + it("rejects when the total payload exceeds the request ceiling", () => { + // Several individually-legal PDFs that together exceed the total cap. + const each = Math.floor(MAX_TOTAL_ATTACHMENT_BYTES / 3); + const content: UserContentPart[] = [ + imagePart(base64OfBytes(each), "application/pdf"), + imagePart(base64OfBytes(each), "application/pdf"), + imagePart(base64OfBytes(each), "application/pdf"), + imagePart(base64OfBytes(each), "application/pdf"), + ]; + const res = validateUserContent(content); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.code === "total-too-large")).toBe(true); + }); +}); diff --git a/packages/core/tests/models/catalog.test.ts b/packages/core/tests/models/catalog.test.ts index 51043e6..f4bddc2 100644 --- a/packages/core/tests/models/catalog.test.ts +++ b/packages/core/tests/models/catalog.test.ts @@ -4,6 +4,7 @@ import { __resetCatalogCacheForTests, getModelsCatalog, resolveContextLimit, + resolveModelCapabilities, } from "../../src/models/catalog.js"; const CACHE_PATH = "/tmp/dispatch/models-dev.json"; @@ -13,14 +14,30 @@ const CATALOG = { anthropic: { id: "anthropic", models: { - "claude-sonnet-4-5": { limit: { context: 200000, output: 64000 } }, - "claude-sonnet-4-6": { limit: { context: 1000000, output: 64000 } }, + "claude-sonnet-4-5": { + limit: { context: 200000, output: 64000 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + }, + "claude-sonnet-4-6": { + limit: { context: 1000000, output: 64000 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + }, + // A text-only model: definitively no image/pdf input. + "text-only-model": { + limit: { context: 100000, output: 8192 }, + modalities: { input: ["text"], output: ["text"] }, + }, + // An entry predating the modalities field → capability unknown. + "legacy-model": { limit: { context: 100000, output: 8192 } }, }, }, opencode: { id: "opencode", models: { - "glm-4-6": { limit: { context: 131072, output: 8192 } }, + "glm-4-6": { + limit: { context: 131072, output: 8192 }, + modalities: { input: ["text", "image"], output: ["text"] }, + }, }, }, }; @@ -156,3 +173,55 @@ describe("getModelsCatalog caching", () => { warn.mockRestore(); }); }); + +describe("resolveModelCapabilities", () => { + it("reports image + pdf for a vision model", async () => { + mockFetchOnce(CATALOG); + expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toEqual({ + image: true, + pdf: true, + }); + }); + + it("reports image-only for a model whose modalities omit pdf", async () => { + mockFetchOnce(CATALOG); + // glm-4-6 lists image but not pdf (resolved via the opencode fallback). + expect(await resolveModelCapabilities("opencode-anthropic", "glm-4-6")).toEqual({ + image: true, + pdf: false, + }); + }); + + it("reports a definitive no for a text-only model", async () => { + mockFetchOnce(CATALOG); + expect(await resolveModelCapabilities("anthropic", "text-only-model")).toEqual({ + image: false, + pdf: false, + }); + }); + + it("returns null (unknown) for an entry without modalities", async () => { + mockFetchOnce(CATALOG); + expect(await resolveModelCapabilities("anthropic", "legacy-model")).toBeNull(); + }); + + it("returns null (unknown) for an unknown model id", async () => { + mockFetchOnce(CATALOG); + expect(await resolveModelCapabilities("anthropic", "no-such-model")).toBeNull(); + }); + + it("returns null for an unsupported provider without hitting the network", async () => { + const fetchFn = mockFetchOnce(CATALOG); + expect(await resolveModelCapabilities("google", "gemini-2.5-pro")).toBeNull(); + expect(await resolveModelCapabilities("anthropic", "")).toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("returns null (unknown) when the catalog is offline with no cache", async () => { + const fetchFn = vi.fn(() => Promise.reject(new Error("offline"))); + vi.stubGlobal("fetch", fetchFn); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toBeNull(); + warn.mockRestore(); + }); +}); |
