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/agent | |
| 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/agent')
| -rw-r--r-- | packages/core/tests/agent/agent.test.ts | 98 |
1 files changed, 98 insertions, 0 deletions
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts index d8edec7..f4b33cc 100644 --- a/packages/core/tests/agent/agent.test.ts +++ b/packages/core/tests/agent/agent.test.ts @@ -1544,4 +1544,102 @@ describe("anthropicThinkingProviderOptions — adaptive-thinking model detection effort: "xhigh", }); }); + + describe("multimodal user content", () => { + it("emits ordered text + image parts to the model when content is provided", async () => { + vi.mocked(streamText).mockReturnValue( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]), + ); + + const agent = new Agent(makeConfig()); + for await (const _ of agent.run("here is image A: [image]", { + content: [ + { type: "text", text: "here is image A: " }, + { type: "attachment", mediaType: "image/png", data: "QQ==" }, + ], + })) { + // consume + } + + const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0]; + const messages = callArgs?.messages as Array<{ role: string; content: unknown }>; + const userMsg = messages.find((m) => m.role === "user"); + expect(userMsg).toBeDefined(); + // Multimodal turn → content is an ordered parts array, not a string. + expect(Array.isArray(userMsg?.content)).toBe(true); + const parts = userMsg?.content as Array<Record<string, unknown>>; + expect(parts[0]).toMatchObject({ type: "text", text: "here is image A: " }); + expect(parts[1]).toMatchObject({ type: "image", mediaType: "image/png" }); + expect(String(parts[1]?.image)).toBe("data:image/png;base64,QQ=="); + }); + + it("emits a FilePart for a PDF attachment with its filename", async () => { + vi.mocked(streamText).mockReturnValue( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]), + ); + + const agent = new Agent(makeConfig()); + for await (const _ of agent.run("see [pdf]", { + content: [ + { type: "text", text: "see " }, + { type: "attachment", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" }, + ], + })) { + // consume + } + + const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0]; + const messages = callArgs?.messages as Array<{ role: string; content: unknown }>; + const userMsg = messages.find((m) => m.role === "user"); + const parts = userMsg?.content as Array<Record<string, unknown>>; + const filePart = parts.find((p) => p.type === "file"); + expect(filePart).toMatchObject({ + type: "file", + mediaType: "application/pdf", + filename: "doc.pdf", + }); + expect(String(filePart?.data)).toBe("data:application/pdf;base64,QQ=="); + }); + + it("persists the user turn as text only (no content) for history", async () => { + vi.mocked(streamText).mockReturnValue( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]), + ); + + const agent = new Agent(makeConfig()); + for await (const _ of agent.run("look: [image]", { + content: [ + { type: "text", text: "look: " }, + { type: "attachment", mediaType: "image/png", data: "QQ==" }, + ], + })) { + // consume + } + + // The in-memory user message keeps the text chunk for the render/persist + // path; the ephemeral `content` rides alongside it but isn't a chunk. + const userMsg = agent.messages.find((m) => m.role === "user"); + expect(userMsg?.chunks).toEqual([{ type: "text", text: "look: [image]" }]); + }); + + it("falls back to a plain string when content has no attachment", async () => { + vi.mocked(streamText).mockReturnValue( + makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]), + ); + + const agent = new Agent(makeConfig()); + for await (const _ of agent.run("plain text", { + content: [{ type: "text", text: "plain text" }], + })) { + // consume + } + + const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0]; + const messages = callArgs?.messages as Array<{ role: string; content: unknown }>; + const userMsg = messages.find((m) => m.role === "user"); + // No attachment → plain string content (byte-identical to text-only path). + expect(typeof userMsg?.content).toBe("string"); + expect(userMsg?.content).toBe("plain text"); + }); + }); }); |
