summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 22:50:11 +0900
committerAdam Malczewski <[email protected]>2026-06-02 22:50:11 +0900
commit66e5d3b105bfd2b34c6f35876bf33dbb3cb9dcae (patch)
treec3e039e09c89231f84dfd16f7bbbf8aedcc2dc7d /packages/frontend/tests
parent4b45d33c256cf580a53054078be6fd7148fa6302 (diff)
downloaddispatch-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/frontend/tests')
-rw-r--r--packages/frontend/tests/attachment-tokens.test.ts130
-rw-r--r--packages/frontend/tests/chat-store.test.ts75
2 files changed, 205 insertions, 0 deletions
diff --git a/packages/frontend/tests/attachment-tokens.test.ts b/packages/frontend/tests/attachment-tokens.test.ts
new file mode 100644
index 0000000..7208cf3
--- /dev/null
+++ b/packages/frontend/tests/attachment-tokens.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeTokenDeletion,
+ findTokens,
+ generateTokenId,
+ intactTokenIds,
+ makeAttachmentToken,
+ markerFor,
+ parseDraft,
+ type StagedAttachment,
+} from "../src/lib/attachment-tokens.js";
+
+function img(id: string): StagedAttachment {
+ return { id, kind: "image", mediaType: "image/png", data: "QQ==" };
+}
+function pdf(id: string): StagedAttachment {
+ return { id, kind: "pdf", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" };
+}
+
+describe("token helpers", () => {
+ it("round-trips make/find", () => {
+ const tok = makeAttachmentToken("image", "abc123");
+ expect(tok).toBe("【image:abc123】");
+ const found = findTokens(`x ${tok} y`);
+ expect(found).toHaveLength(1);
+ expect(found[0]).toMatchObject({ id: "abc123", kind: "image", start: 2, end: 2 + tok.length });
+ });
+
+ it("generates 6-char lowercase-alnum ids", () => {
+ for (let i = 0; i < 20; i++) {
+ expect(generateTokenId()).toMatch(/^[a-z0-9]{6}$/);
+ }
+ });
+
+ it("finds multiple tokens in order and reports intact ids", () => {
+ const text = `a ${makeAttachmentToken("image", "aaaaaa")} b ${makeAttachmentToken("pdf", "bbbbbb")}`;
+ const found = findTokens(text);
+ expect(found.map((t) => t.id)).toEqual(["aaaaaa", "bbbbbb"]);
+ expect(intactTokenIds(text)).toEqual(new Set(["aaaaaa", "bbbbbb"]));
+ });
+
+ it("does not treat a partially-broken token as intact", () => {
+ // Missing closing bracket → not a valid token.
+ expect(intactTokenIds("【image:aaaaaa").size).toBe(0);
+ });
+});
+
+describe("computeTokenDeletion", () => {
+ const tok = makeAttachmentToken("image", "abcabc");
+ const text = `hi ${tok}!`; // token spans indices 3..3+len
+ const tokStart = 3;
+ const tokEnd = 3 + tok.length;
+
+ it("returns null when no tokens exist", () => {
+ expect(computeTokenDeletion("plain", 2, 2, "Backspace")).toBeNull();
+ });
+
+ it("Backspace just after a token removes the whole token atomically", () => {
+ const res = computeTokenDeletion(text, tokEnd, tokEnd, "Backspace");
+ expect(res).not.toBeNull();
+ expect(res?.text).toBe("hi !");
+ expect(res?.caret).toBe(tokStart);
+ expect(res?.removedIds).toEqual(["abcabc"]);
+ });
+
+ it("Delete just before a token removes the whole token atomically", () => {
+ const res = computeTokenDeletion(text, tokStart, tokStart, "Delete");
+ expect(res?.text).toBe("hi !");
+ expect(res?.caret).toBe(tokStart);
+ expect(res?.removedIds).toEqual(["abcabc"]);
+ });
+
+ it("Backspace NOT adjacent to a token returns null (default editing)", () => {
+ // Caret at index 2 (after "hi"), token is further along.
+ expect(computeTokenDeletion(text, 2, 2, "Backspace")).toBeNull();
+ });
+
+ it("a selection overlapping a token expands to cover the whole token", () => {
+ // Select from inside "hi " through the middle of the token.
+ const res = computeTokenDeletion(text, 1, tokStart + 3, "Backspace");
+ expect(res).not.toBeNull();
+ // Deletion starts at min(selStart, tokStart)=1 and ends at tokEnd.
+ expect(res?.text).toBe("h!");
+ expect(res?.removedIds).toEqual(["abcabc"]);
+ });
+
+ it("a range selection touching no token returns null", () => {
+ expect(computeTokenDeletion(text, 0, 2, "Backspace")).toBeNull();
+ });
+});
+
+describe("parseDraft", () => {
+ it("returns plain text + null content when there are no attachments", () => {
+ const res = parseDraft("just text", new Map());
+ expect(res.displayText).toBe("just text");
+ expect(res.content).toBeNull();
+ });
+
+ it("interleaves text and attachment parts in order", () => {
+ const a = img("aaaaaa");
+ const b = pdf("bbbbbb");
+ const map = new Map([
+ [a.id, a],
+ [b.id, b],
+ ]);
+ const draft = `A: ${makeAttachmentToken("image", a.id)} B: ${makeAttachmentToken("pdf", b.id)} end`;
+ const res = parseDraft(draft, map);
+
+ // displayText swaps tokens for markers.
+ expect(res.displayText).toBe(`A: ${markerFor("image")} B: ${markerFor("pdf")} end`);
+
+ // content interleaves the surrounding text with the attachment parts.
+ expect(res.content).toEqual([
+ { type: "text", text: "A: " },
+ { type: "attachment", mediaType: "image/png", data: "QQ==" },
+ { type: "text", text: " B: " },
+ { type: "attachment", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" },
+ { type: "text", text: " end" },
+ ]);
+ });
+
+ it("treats an orphan token (no staged attachment) as plain text", () => {
+ // Token present in text but not in the attachments map.
+ const draft = `x ${makeAttachmentToken("image", "zzzzzz")} y`;
+ const res = parseDraft(draft, new Map());
+ expect(res.displayText).toBe(`x ${markerFor("image")} y`);
+ // No real attachment → null content (plain-text send).
+ expect(res.content).toBeNull();
+ });
+});
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
index a0d4ead..8639bff 100644
--- a/packages/frontend/tests/chat-store.test.ts
+++ b/packages/frontend/tests/chat-store.test.ts
@@ -2126,3 +2126,78 @@ describe("tabStore — per-tab chat input draft", () => {
expect(store.tabs.every((t) => t.draft === "")).toBe(true);
});
});
+
+describe("tabStore — image/pdf attachments", () => {
+ function imgAttachment(id: string) {
+ return { id, kind: "image" as const, mediaType: "image/png", data: "QQ==" };
+ }
+
+ it("stages attachments and reconciles them against intact draft tokens", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
+ );
+ const store = createTabStore();
+ const a = await store.createNewTab();
+ store.switchTab(a.id);
+
+ store.addAttachment(a.id, imgAttachment("aaaaaa"));
+ // Draft carries the token → attachment survives.
+ store.setDraft(a.id, "look 【image:aaaaaa】");
+ expect(store.activeTab?.attachments.map((x) => x.id)).toEqual(["aaaaaa"]);
+
+ // Remove the token from the draft → attachment is detached.
+ store.setDraft(a.id, "look ");
+ expect(store.activeTab?.attachments).toHaveLength(0);
+ });
+
+ it("sendMessage posts ordered multimodal content and clears the draft", async () => {
+ const fetchMock = vi.fn((url: string) => {
+ if (typeof url === "string" && url.endsWith("/chat")) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) });
+ }
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const store = createTabStore();
+ const a = await store.createNewTab();
+ store.switchTab(a.id);
+
+ await store.sendMessage("here is A: [image]", [
+ { type: "text", text: "here is A: " },
+ { type: "attachment", mediaType: "image/png", data: "QQ==" },
+ ]);
+
+ const chatCall = fetchMock.mock.calls.find(
+ (c) => typeof c[0] === "string" && (c[0] as string).endsWith("/chat"),
+ );
+ expect(chatCall).toBeDefined();
+ const body = JSON.parse((chatCall?.[1] as { body: string }).body);
+ expect(body.message).toBe("here is A: [image]");
+ expect(body.content).toEqual([
+ { type: "text", text: "here is A: " },
+ { type: "attachment", mediaType: "image/png", data: "QQ==" },
+ ]);
+ });
+
+ it("sendMessage omits content for a plain-text message", async () => {
+ const fetchMock = vi.fn((url: string) => {
+ if (typeof url === "string" && url.endsWith("/chat")) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) });
+ }
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const store = createTabStore();
+ await store.createNewTab();
+ await store.sendMessage("just text");
+
+ const chatCall = fetchMock.mock.calls.find(
+ (c) => typeof c[0] === "string" && (c[0] as string).endsWith("/chat"),
+ );
+ const body = JSON.parse((chatCall?.[1] as { body: string }).body);
+ expect(body.content).toBeUndefined();
+ });
+});